lib.itmens/catalog/tv/models.py

532 lines
16 KiB
Python
Raw Normal View History

"""
Models for TV
TVShow -> TVSeason -> TVEpisode
TVEpisode is not fully implemented at the moment
Three way linking between Douban / IMDB / TMDB are quite messy
IMDB:
2023-02-15 15:45:57 -05:00
most widely used.
no ID for Season, only for Show and Episode
TMDB:
most friendly API.
for some TV specials, both shown as an Episode of Season 0 and a Movie, with same IMDB id
Douban:
most wanted by our users.
for single season show, IMDB id of the show id used
for multi season show, IMDB id for Ep 1 will be used to repensent that season
tv specials are are shown as movies
For now, we follow Douban convention, but keep an eye on it in case it breaks its own rules...
"""
2024-06-02 14:50:07 -04:00
2023-02-15 15:45:57 -05:00
from functools import cached_property
from typing import TYPE_CHECKING
from django.db import models
2022-12-16 01:08:10 -05:00
from django.utils.translation import gettext_lazy as _
2023-02-15 15:45:57 -05:00
2023-08-11 01:43:19 -04:00
from catalog.common import (
BaseSchema,
ExternalResource,
IdType,
Item,
ItemCategory,
ItemInSchema,
ItemSchema,
PrimaryLookupIdDescriptor,
jsondata,
)
2024-07-13 00:16:47 -04:00
from catalog.common.models import LANGUAGE_CHOICES_JSONFORM, LanguageListField
2024-07-14 13:36:52 -04:00
from common.models.lang import RE_LOCALIZED_SEASON_NUMBERS, localize_number
2025-03-09 12:22:45 -04:00
from common.models.misc import uniq
2023-02-15 15:45:57 -05:00
class TVShowInSchema(ItemInSchema):
season_count: int | None = None
orig_title: str | None = None
other_title: list[str]
2023-02-15 15:45:57 -05:00
director: list[str]
playwright: list[str]
actor: list[str]
genre: list[str]
language: list[str]
area: list[str]
year: int | None = None
site: str | None = None
episode_count: int | None = None
2025-01-25 16:05:00 -05:00
season_uuids: list[str]
2023-02-15 15:45:57 -05:00
class TVShowSchema(TVShowInSchema, BaseSchema):
2023-02-15 16:22:32 -05:00
imdb: str | None = None
2023-02-15 15:45:57 -05:00
# seasons: list['TVSeason']
pass
class TVSeasonInSchema(ItemInSchema):
season_number: int | None = None
orig_title: str | None = None
other_title: list[str]
2023-02-15 15:45:57 -05:00
director: list[str]
playwright: list[str]
actor: list[str]
genre: list[str]
language: list[str]
area: list[str]
year: int | None = None
site: str | None = None
episode_count: int | None = None
2023-06-19 21:32:11 -04:00
episode_uuids: list[str]
2023-02-15 15:45:57 -05:00
class TVSeasonSchema(TVSeasonInSchema, BaseSchema):
2024-11-19 22:38:13 -05:00
imdb: str | None = None
2023-06-19 21:32:11 -04:00
class TVEpisodeSchema(ItemSchema):
episode_number: int | None = None
class TVShow(Item):
2024-05-27 15:44:12 -04:00
if TYPE_CHECKING:
2025-02-23 13:40:45 -05:00
seasons: models.QuerySet["TVSeason"]
2025-01-28 21:38:02 -05:00
schema = TVShowSchema
2023-06-05 18:57:52 -04:00
child_class = "TVSeason"
2022-12-11 23:20:28 +00:00
category = ItemCategory.TV
2022-12-29 23:57:02 -05:00
url_path = "tv"
imdb = PrimaryLookupIdDescriptor(IdType.IMDB)
tmdb_tv = PrimaryLookupIdDescriptor(IdType.TMDB_TV)
imdb = PrimaryLookupIdDescriptor(IdType.IMDB)
2024-03-10 20:55:50 -04:00
season_count = models.IntegerField(
verbose_name=_("number of seasons"), null=True, blank=True
)
2023-01-05 03:06:13 -05:00
episode_count = models.PositiveIntegerField(
2024-03-10 20:55:50 -04:00
verbose_name=_("number of episodes"), null=True, blank=True
2023-01-05 03:06:13 -05:00
)
2022-12-16 01:08:10 -05:00
METADATA_COPY_LIST = [
2024-07-13 00:16:47 -04:00
# "title",
"localized_title",
2022-12-29 23:57:02 -05:00
"season_count",
"orig_title",
2024-07-13 00:16:47 -04:00
# "other_title",
2022-12-29 23:57:02 -05:00
"director",
"playwright",
"actor",
2024-07-13 00:16:47 -04:00
# "brief",
"localized_description",
2022-12-29 23:57:02 -05:00
"genre",
"showtime",
"site",
"area",
"language",
"year",
"duration",
"episode_count",
"single_episode_length",
2022-12-16 01:08:10 -05:00
]
2022-12-29 23:57:02 -05:00
orig_title = jsondata.CharField(
2024-07-16 02:28:53 -04:00
verbose_name=_("original title"), blank=True, max_length=500
2022-12-29 23:57:02 -05:00
)
other_title = jsondata.ArrayField(
base_field=models.CharField(blank=True, default="", max_length=500),
2024-03-10 20:55:50 -04:00
verbose_name=_("other title"),
null=True,
blank=True,
default=list,
2022-12-29 23:57:02 -05:00
)
director = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("director"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=200),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
)
playwright = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("playwright"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=200),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
)
actor = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("actor"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=200),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
)
genre = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("genre"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=50),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
) # , choices=MovieGenreEnum.choices
2023-01-05 03:06:13 -05:00
showtime = jsondata.JSONField(
2024-03-10 20:55:50 -04:00
_("show time"),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
schema={
2023-06-09 13:34:36 -04:00
"type": "list",
2023-06-07 04:14:38 -04:00
"items": {
"type": "dict",
"additionalProperties": False,
2023-06-09 13:34:36 -04:00
"keys": {
"time": {
"type": "string",
2024-03-10 20:55:50 -04:00
"title": _("Date"),
"placeholder": _("YYYY-MM-DD"),
2023-06-09 13:34:36 -04:00
},
"region": {
"type": "string",
2024-03-10 20:55:50 -04:00
"title": _("Region or Event"),
"placeholder": _(
"Germany or Toronto International Film Festival"
),
2023-06-09 13:34:36 -04:00
},
},
2023-06-07 04:14:38 -04:00
"required": ["time"],
},
},
2022-12-29 23:57:02 -05:00
)
2024-07-16 02:28:53 -04:00
site = jsondata.URLField(verbose_name=_("website"), blank=True, max_length=200)
2022-12-29 23:57:02 -05:00
area = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("region"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(
2022-12-29 23:57:02 -05:00
blank=True,
default="",
max_length=100,
),
null=True,
blank=True,
default=list,
)
2024-07-13 00:16:47 -04:00
language = LanguageListField()
2024-03-10 20:55:50 -04:00
year = jsondata.IntegerField(verbose_name=_("year"), null=True, blank=True)
2023-01-05 03:06:13 -05:00
single_episode_length = jsondata.IntegerField(
2024-03-10 20:55:50 -04:00
verbose_name=_("episode length"), null=True, blank=True
2023-01-05 03:06:13 -05:00
)
season_number = jsondata.IntegerField(
null=True, blank=True
) # TODO remove after migration
duration = jsondata.CharField(
2024-07-16 02:28:53 -04:00
blank=True, max_length=200
2023-01-05 03:06:13 -05:00
) # TODO remove after migration
@classmethod
def lookup_id_type_choices(cls):
id_types = [
IdType.IMDB,
IdType.TMDB_TV,
IdType.DoubanMovie,
IdType.Bangumi,
]
return [(i.value, i.label) for i in id_types]
@cached_property
def all_seasons(self):
2023-05-21 18:32:38 -04:00
return (
self.seasons.all()
.order_by("season_number")
.filter(is_deleted=False, merged_to_item=None)
)
@property
def child_items(self):
return self.all_seasons
2025-01-25 16:05:00 -05:00
@property
def season_uuids(self):
return [x.uuid for x in self.all_seasons]
2024-07-14 13:36:52 -04:00
def get_season_count(self):
return self.season_count or self.seasons.all().count()
2024-12-30 01:51:19 -05:00
def to_indexable_titles(self) -> list[str]:
2024-12-30 09:35:58 -05:00
titles = [t["text"] for t in self.localized_title if t["text"]]
2024-12-30 01:51:19 -05:00
titles += [self.orig_title] if self.orig_title else []
return list(set(titles))
class TVSeason(Item):
2024-05-27 15:44:12 -04:00
if TYPE_CHECKING:
episodes: models.QuerySet["TVEpisode"]
2025-01-28 21:38:02 -05:00
schema = TVSeasonSchema
2022-12-11 23:20:28 +00:00
category = ItemCategory.TV
2022-12-29 23:57:02 -05:00
url_path = "tv/season"
child_class = "TVEpisode"
douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie)
imdb = PrimaryLookupIdDescriptor(IdType.IMDB)
tmdb_tvseason = PrimaryLookupIdDescriptor(IdType.TMDB_TVSeason)
2022-12-29 23:57:02 -05:00
show = models.ForeignKey(
TVShow, null=True, on_delete=models.SET_NULL, related_name="seasons"
)
2024-03-10 20:55:50 -04:00
season_number = models.PositiveIntegerField(
verbose_name=_("season number"), null=True
)
episode_count = models.PositiveIntegerField(
verbose_name=_("number of episodes"), null=True
)
2022-12-16 01:08:10 -05:00
METADATA_COPY_LIST = [
# "title",
"localized_title",
2023-01-05 03:06:13 -05:00
"season_number",
"episode_count",
2022-12-29 23:57:02 -05:00
"orig_title",
# "other_title",
2022-12-29 23:57:02 -05:00
"director",
"playwright",
"actor",
"genre",
"showtime",
"site",
"area",
"language",
"year",
"duration",
"single_episode_length",
"localized_description",
# "brief",
2022-12-16 01:08:10 -05:00
]
2022-12-29 23:57:02 -05:00
orig_title = jsondata.CharField(
2024-03-10 20:55:50 -04:00
verbose_name=_("original title"), blank=True, default="", max_length=500
2022-12-29 23:57:02 -05:00
)
other_title = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("other title"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=500),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
)
director = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("director"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=200),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
)
playwright = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("playwright"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=200),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
)
actor = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("actor"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=200),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
)
genre = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("genre"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(blank=True, default="", max_length=50),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
) # , choices=MovieGenreEnum.choices
2023-01-05 03:06:13 -05:00
showtime = jsondata.JSONField(
2024-03-10 20:55:50 -04:00
_("show time"),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
schema={
2023-06-09 13:34:36 -04:00
"type": "list",
2023-06-07 04:14:38 -04:00
"items": {
"type": "dict",
"additionalProperties": False,
2023-06-09 13:34:36 -04:00
"keys": {
"time": {
"type": "string",
2024-03-10 20:55:50 -04:00
"title": _("date"),
"placeholder": _("required"),
2023-06-09 13:34:36 -04:00
},
"region": {
"type": "string",
2024-03-10 20:55:50 -04:00
"title": _("region or event"),
"placeholder": _(
"Germany or Toronto International Film Festival"
),
2023-06-09 13:34:36 -04:00
},
},
2023-06-07 04:14:38 -04:00
"required": ["time"],
},
},
2022-12-29 23:57:02 -05:00
)
2023-01-05 03:06:13 -05:00
site = jsondata.URLField(
2024-03-10 20:55:50 -04:00
verbose_name=_("website"), blank=True, default="", max_length=200
2023-01-05 03:06:13 -05:00
)
2022-12-29 23:57:02 -05:00
area = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("region"),
2023-01-05 03:06:13 -05:00
base_field=models.CharField(
2022-12-29 23:57:02 -05:00
blank=True,
default="",
max_length=100,
),
null=True,
blank=True,
default=list,
)
2024-11-22 23:44:14 -05:00
language = jsondata.ArrayField(
2024-03-10 20:55:50 -04:00
verbose_name=_("language"),
2024-11-22 23:44:14 -05:00
base_field=models.CharField(blank=True, default="", max_length=100),
2022-12-29 23:57:02 -05:00
null=True,
blank=True,
default=list,
2024-07-13 00:16:47 -04:00
schema={
"type": "list",
"items": {"type": "string", "choices": LANGUAGE_CHOICES_JSONFORM},
},
2022-12-29 23:57:02 -05:00
)
2024-03-10 20:55:50 -04:00
year = jsondata.IntegerField(verbose_name=_("year"), null=True, blank=True)
2023-01-05 03:06:13 -05:00
single_episode_length = jsondata.IntegerField(
2024-03-10 20:55:50 -04:00
verbose_name=_("episode length"), null=True, blank=True
2023-01-05 03:06:13 -05:00
)
duration = jsondata.CharField(
blank=True, default="", max_length=200
) # TODO remove after migration
@classmethod
def lookup_id_type_choices(cls):
id_types = [
IdType.IMDB,
IdType.TMDB_TVSeason,
IdType.DoubanMovie,
]
return [(i.value, i.label) for i in id_types]
2024-07-14 13:36:52 -04:00
@cached_property
2023-06-05 02:46:26 -04:00
def display_title(self):
2024-07-14 13:36:52 -04:00
"""
returns season title for display:
- "Season Title" if it's not a bare "Season X"
- "Show Title" if it's the only season
- "Show Title Season X" with some localization
"""
s = super().display_title
2025-03-09 12:22:45 -04:00
if self.parent_item:
if (
RE_LOCALIZED_SEASON_NUMBERS.sub("", s) == ""
or s == self.parent_item.display_title
):
if self.parent_item.get_season_count() == 1:
return self.parent_item.display_title
elif self.season_number:
return _("{show_title} Season {season_number}").format(
show_title=self.parent_item.display_title,
season_number=localize_number(self.season_number),
)
else:
return f"{self.parent_item.display_title} {s}"
elif self.parent_item.display_title not in s:
return f"{self.parent_item.display_title} ({s})"
2024-07-14 13:36:52 -04:00
return s
2023-06-05 02:04:52 -04:00
@cached_property
def additional_title(self) -> list[str]:
title = self.display_title
2025-03-09 12:22:45 -04:00
return uniq(
[
t["text"]
for t in self.localized_title
if t["text"] != title
and RE_LOCALIZED_SEASON_NUMBERS.sub("", t["text"]) != ""
]
)
2024-12-30 01:51:19 -05:00
def to_indexable_titles(self) -> list[str]:
2024-12-30 09:35:58 -05:00
titles = [t["text"] for t in self.localized_title if t["text"]]
2024-12-30 01:51:19 -05:00
titles += [self.orig_title] if self.orig_title else []
titles += self.parent_item.to_indexable_titles() if self.parent_item else []
return list(set(titles))
2022-12-08 16:08:59 +00:00
def update_linked_items_from_external_resource(self, resource):
2023-06-05 02:04:52 -04:00
for w in resource.required_resources:
2022-12-29 23:57:02 -05:00
if w["model"] == "TVShow":
p = ExternalResource.objects.filter(
id_type=w["id_type"], id_value=w["id_value"]
).first()
2023-06-05 02:04:52 -04:00
if p and p.item:
self.show = p.item
def all_seasons(self):
return self.show.all_seasons if self.show else []
2023-06-19 20:49:57 -04:00
@cached_property
def all_episodes(self):
return self.episodes.all().order_by("episode_number")
2023-02-15 15:45:57 -05:00
@property
2024-05-27 15:44:12 -04:00
def parent_item(self) -> TVShow | None: # type:ignore
2023-06-05 02:04:52 -04:00
return self.show
2023-02-15 15:45:57 -05:00
2024-05-27 15:44:12 -04:00
def set_parent_item(self, value: TVShow | None): # type:ignore
2023-06-05 18:57:52 -04:00
self.show = value
@property
def child_items(self):
return self.episodes.all()
2023-06-19 21:32:11 -04:00
@property
def episode_uuids(self):
return [x.uuid for x in self.all_episodes]
class TVEpisode(Item):
2025-01-28 21:38:02 -05:00
schema = TVEpisodeSchema
2022-12-11 23:20:28 +00:00
category = ItemCategory.TV
2022-12-29 23:57:02 -05:00
url_path = "tv/episode"
season = models.ForeignKey(
TVSeason, null=True, on_delete=models.SET_NULL, related_name="episodes"
)
season_number = jsondata.IntegerField(null=True)
2022-12-16 01:08:10 -05:00
episode_number = models.PositiveIntegerField(null=True)
imdb = PrimaryLookupIdDescriptor(IdType.IMDB)
METADATA_COPY_LIST = ["title", "brief", "season_number", "episode_number"]
@property
def display_title(self):
2024-05-15 20:41:03 -04:00
return (
_("{season_title} E{episode_number}")
.format(
season_title=self.season.display_title if self.season else "",
episode_number=self.episode_number,
)
.strip()
)
@property
2024-05-27 15:44:12 -04:00
def parent_item(self) -> TVSeason | None: # type:ignore
return self.season
2024-05-27 15:44:12 -04:00
def set_parent_item(self, value: TVSeason | None): # type:ignore
self.season = value
@classmethod
def lookup_id_type_choices(cls):
id_types = [
IdType.IMDB,
IdType.TMDB_TVEpisode,
]
return [(i.value, i.label) for i in id_types]
2024-05-27 15:44:12 -04:00
def update_linked_items_from_external_resource(self, resource: ExternalResource):
for w in resource.required_resources:
if w["model"] == "TVSeason":
p = ExternalResource.objects.filter(
id_type=w["id_type"], id_value=w["id_value"]
).first()
if p and p.item:
self.season = p.item