Add seasons

This commit is contained in:
Zoe Roux 2023-03-26 02:09:18 +09:00
parent 408873b844
commit 75fb4b5809
12 changed files with 84 additions and 31 deletions

View File

@ -39,7 +39,7 @@ namespace Kyoo.Abstractions.Models
{ {
get get
{ {
if (ShowSlug != null || Show != null) if (ShowSlug != null || Show?.Slug != null)
return GetSlug(ShowSlug ?? Show.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber); return GetSlug(ShowSlug ?? Show.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber);
return ShowID != 0 return ShowID != 0
? GetSlug(ShowID.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber) ? GetSlug(ShowID.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber)

View File

@ -97,7 +97,7 @@ namespace Kyoo.Core.Api
MemberExpression propertyExpr = Expression.Property(param, property); MemberExpression propertyExpr = Expression.Property(param, property);
ConstantExpression valueExpr = null; ConstantExpression valueExpr = null;
bool isList = typeof(IEnumerable).IsAssignableFrom(propertyExpr.Type); bool isList = typeof(IEnumerable).IsAssignableFrom(propertyExpr.Type) && propertyExpr.Type != typeof(string);
if (operand != "ctn" && !typeof(IResource).IsAssignableFrom(propertyExpr.Type) && !isList) if (operand != "ctn" && !typeof(IResource).IsAssignableFrom(propertyExpr.Type) && !isList)
{ {
Type propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; Type propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;

View File

@ -143,7 +143,7 @@ namespace Kyoo.Host
.UseKestrel(options => { options.AddServerHeader = false; }) .UseKestrel(options => { options.AddServerHeader = false; })
.UseIIS() .UseIIS()
.UseIISIntegration() .UseIISIntegration()
.UseUrls("http://*:5000") .UseUrls(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000")
.UseStartup(host => PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog())) .UseStartup(host => PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog()))
); );
} }

View File

@ -480,7 +480,7 @@ namespace Kyoo.Postgresql
{ {
try try
{ {
return await base.SaveChangesAsync(cancellationToken); return await SaveChangesAsync(cancellationToken);
} }
catch (DbUpdateException ex) catch (DbUpdateException ex)
{ {
@ -489,6 +489,10 @@ namespace Kyoo.Postgresql
throw new DuplicatedItemException(await getExisting()); throw new DuplicatedItemException(await getExisting());
throw; throw;
} }
catch (DuplicatedItemException)
{
throw new DuplicatedItemException(await getExisting());
}
} }
/// <summary> /// <summary>

View File

@ -58,6 +58,10 @@ services:
- ./.env - ./.env
volumes: volumes:
- db:/var/lib/postgresql/data - db:/var/lib/postgresql/data
ports:
- "5432:5432"
environment:
- POSTGRES_HOST_AUTH_METHOD=trust
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s interval: 5s

View File

@ -87,7 +87,7 @@ class TheMovieDatabase(Provider):
logos=[f"https://image.tmdb.org/t/p/original{company['logo_path']}"] logos=[f"https://image.tmdb.org/t/p/original{company['logo_path']}"]
if "logo_path" in company if "logo_path" in company
else [], else [],
external_id={ external_ids={
self.name: MetadataID( self.name: MetadataID(
company["id"], f"https://www.themoviedb.org/company/{company['id']}" company["id"], f"https://www.themoviedb.org/company/{company['id']}"
) )
@ -130,7 +130,7 @@ class TheMovieDatabase(Provider):
for x in movie["genres"] for x in movie["genres"]
if x["id"] in self.genre_map if x["id"] in self.genre_map
], ],
external_id={ external_ids={
self.name: MetadataID( self.name: MetadataID(
movie["id"], f"https://www.themoviedb.org/movie/{movie['id']}" movie["id"], f"https://www.themoviedb.org/movie/{movie['id']}"
), ),
@ -166,7 +166,7 @@ class TheMovieDatabase(Provider):
*, *,
language: list[str], language: list[str],
) -> Show: ) -> Show:
show_id = show.external_id[self.name].id show_id = show.external_ids[self.name].id
if show.original_language not in language: if show.original_language not in language:
language.append(show.original_language) language.append(show.original_language)
@ -197,7 +197,7 @@ class TheMovieDatabase(Provider):
for x in show["genres"] for x in show["genres"]
if x["id"] in self.genre_map if x["id"] in self.genre_map
], ],
external_id={ external_ids={
self.name: MetadataID( self.name: MetadataID(
show["id"], f"https://www.themoviedb.org/tv/{show['id']}" show["id"], f"https://www.themoviedb.org/tv/{show['id']}"
), ),
@ -260,7 +260,7 @@ class TheMovieDatabase(Provider):
season_number=season["season_number"], season_number=season["season_number"],
start_air=datetime.strptime(season["air_date"], "%Y-%m-%d").date(), start_air=datetime.strptime(season["air_date"], "%Y-%m-%d").date(),
end_air=None, end_air=None,
external_id={ external_ids={
self.name: MetadataID( self.name: MetadataID(
season["id"], season["id"],
f"https://www.themoviedb.org/tv/{show_id}/season/{season['season_number']}", f"https://www.themoviedb.org/tv/{show_id}/season/{season['season_number']}",
@ -313,7 +313,7 @@ class TheMovieDatabase(Provider):
show=PartialShow( show=PartialShow(
name=search["name"], name=search["name"],
original_language=search["original_language"], original_language=search["original_language"],
external_id={ external_ids={
self.name: MetadataID( self.name: MetadataID(
show_id, f"https://www.themoviedb.org/tv/{show_id}" show_id, f"https://www.themoviedb.org/tv/{show_id}"
) )
@ -327,7 +327,7 @@ class TheMovieDatabase(Provider):
thumbnail=f"https://image.tmdb.org/t/p/original{episode['poster_path']}" thumbnail=f"https://image.tmdb.org/t/p/original{episode['poster_path']}"
if "poster_path" in episode if "poster_path" in episode
else None, else None,
external_id={ external_ids={
self.name: MetadataID( self.name: MetadataID(
episode["id"], episode["id"],
f"https://www.themoviedb.org/tv/{show_id}/season/{episode['season_number']}/episode/{episode['episode_number']}", f"https://www.themoviedb.org/tv/{show_id}/season/{episode['season_number']}/episode/{episode['episode_number']}",

View File

@ -13,7 +13,7 @@ from .metadataid import MetadataID
class PartialShow: class PartialShow:
name: str name: str
original_language: str original_language: str
external_id: dict[str, MetadataID] external_ids: dict[str, MetadataID]
@dataclass @dataclass
@ -30,7 +30,7 @@ class Episode:
absolute_number: Optional[int] absolute_number: Optional[int]
release_date: Optional[date | int] release_date: Optional[date | int]
thumbnail: Optional[str] thumbnail: Optional[str]
external_id: dict[str, MetadataID] external_ids: dict[str, MetadataID]
path: Optional[str] = None path: Optional[str] = None
show_id: Optional[str] = None show_id: Optional[str] = None
@ -43,4 +43,8 @@ class Episode:
return { return {
**asdict(self), **asdict(self),
**asdict(self.translations[default_language]), **asdict(self.translations[default_language]),
"title": self.translations[default_language].name,
# TODO: The back has bad external id support, we disable it for now
"external_ids": None,
"show": None,
} }

View File

@ -40,7 +40,7 @@ class Movie:
genres: list[Genre] = field(default_factory=list) genres: list[Genre] = field(default_factory=list)
# TODO: handle staff # TODO: handle staff
# staff: list[Staff] # staff: list[Staff]
external_id: dict[str, MetadataID] = field(default_factory=dict) external_ids: dict[str, MetadataID] = field(default_factory=dict)
translations: dict[str, MovieTranslation] = field(default_factory=dict) translations: dict[str, MovieTranslation] = field(default_factory=dict)
@ -56,10 +56,12 @@ class Movie:
), ),
"logo": next(iter(self.translations[default_language].logos), None), "logo": next(iter(self.translations[default_language].logos), None),
"trailer": next(iter(self.translations[default_language].trailers), None), "trailer": next(iter(self.translations[default_language].trailers), None),
"studio": next(iter(self.studios), None), "studio": next((x.to_kyoo() for x in self.studios), None),
"release_date": None, "release_date": None,
"startAir": format_date(self.release_date), "startAir": format_date(self.release_date),
"title": self.translations[default_language].name, "title": self.translations[default_language].name,
"genres": [x.to_kyoo() for x in self.genres], "genres": [x.to_kyoo() for x in self.genres],
"isMovie": True, "isMovie": True,
# TODO: The back has bad external id support, we disable it for now
"external_ids": None,
} }

View File

@ -20,8 +20,9 @@ class Season:
season_number: int season_number: int
start_air: Optional[date | int] start_air: Optional[date | int]
end_air: Optional[date | int] end_air: Optional[date | int]
external_id: dict[str, MetadataID] external_ids: dict[str, MetadataID]
show_id: Optional[str] = None
translations: dict[str, SeasonTranslation] = field(default_factory=dict) translations: dict[str, SeasonTranslation] = field(default_factory=dict)
def to_kyoo(self): def to_kyoo(self):
@ -34,4 +35,7 @@ class Season:
"thumbnail": next( "thumbnail": next(
iter(self.translations[default_language].thumbnails), None iter(self.translations[default_language].thumbnails), None
), ),
"title": self.translations[default_language].name,
# TODO: The back has bad external id support, we disable it for now
"external_ids": None,
} }

View File

@ -43,7 +43,7 @@ class Show:
seasons: list[Season] seasons: list[Season]
# TODO: handle staff # TODO: handle staff
# staff: list[Staff] # staff: list[Staff]
external_id: dict[str, MetadataID] external_ids: dict[str, MetadataID]
translations: dict[str, ShowTranslation] = field(default_factory=dict) translations: dict[str, ShowTranslation] = field(default_factory=dict)
@ -59,8 +59,10 @@ class Show:
), ),
"logo": next(iter(self.translations[default_language].logos), None), "logo": next(iter(self.translations[default_language].logos), None),
"trailer": next(iter(self.translations[default_language].trailers), None), "trailer": next(iter(self.translations[default_language].trailers), None),
"studio": next(iter(self.studios), None), "studio": next((x.to_kyoo() for x in self.studios), None),
"title": self.translations[default_language].name, "title": self.translations[default_language].name,
"genres": [x.to_kyoo() for x in self.genres], "genres": [x.to_kyoo() for x in self.genres],
"seasons": [x.to_kyoo() for x in self.seasons], "seasons": None,
# TODO: The back has bad external id support, we disable it for now
"external_ids": None,
} }

View File

@ -7,4 +7,11 @@ from .metadataid import MetadataID
class Studio: class Studio:
name: str name: str
logos: list[str] = field(default_factory=list) logos: list[str] = field(default_factory=list)
external_id: dict[str, MetadataID] = field(default_factory=dict) external_ids: dict[str, MetadataID] = field(default_factory=dict)
def to_kyoo(self):
return {
**asdict(self),
# TODO: The back has bad external id support, we disable it for now
"external_ids": None,
}

View File

@ -8,7 +8,7 @@ from pathlib import Path
from guessit import guessit from guessit import guessit
from providers.provider import Provider from providers.provider import Provider
from providers.types.episode import Episode, PartialShow from providers.types.episode import Episode, PartialShow
from providers.types.show import Show from providers.types.season import Season
def log_errors(f): def log_errors(f):
@ -28,6 +28,7 @@ class Scanner:
) -> None: ) -> None:
self._client = client self._client = client
self._api_key = api_key self._api_key = api_key
self._url = os.environ.get("KYOO_URL", "http://back:5000")
self.provider = Provider.get_all(client)[0] self.provider = Provider.get_all(client)[0]
self.cache = {"shows": {}} self.cache = {"shows": {}}
self.languages = languages self.languages = languages
@ -36,15 +37,30 @@ class Scanner:
videos = filter(lambda p: p.is_file(), Path(path).rglob("*")) videos = filter(lambda p: p.is_file(), Path(path).rglob("*"))
await asyncio.gather(*map(self.identify, videos)) await asyncio.gather(*map(self.identify, videos))
async def is_registered(self, path: Path) -> bool:
# TODO: Once movies are separated from the api, a new endpoint should be created to check for paths.
async with self._client.get(
f"{self._url}/episodes/count",
params={"path": str(path)},
headers={"X-API-Key": self._api_key},
) as r:
r.raise_for_status()
ret = await r.text()
if ret != "0":
return True
return False
@log_errors @log_errors
async def identify(self, path: Path): async def identify(self, path: Path):
if await self.is_registered(path):
return
raw = guessit(path, "--episode-prefer-number") raw = guessit(path, "--episode-prefer-number")
logging.info("Identied %s: %s", path, raw) logging.info("Identied %s: %s", path, raw)
# TODO: check if episode/movie already exists in kyoo and skip if it does. # TODO: check if episode/movie already exists in kyoo and skip if it does.
# TODO: Add collections support # TODO: Add collections support
if raw["type"] == "movie": if raw["type"] == "movie":
return
movie = await self.provider.identify_movie( movie = await self.provider.identify_movie(
raw["title"], raw.get("year"), language=self.languages raw["title"], raw.get("year"), language=self.languages
) )
@ -62,21 +78,24 @@ class Scanner:
episode.path = str(path) episode.path = str(path)
logging.debug("Got episode: %s", episode) logging.debug("Got episode: %s", episode)
episode.show_id = await self.create_or_get_show(episode) episode.show_id = await self.create_or_get_show(episode)
# TODO: Do the same things for seasons and wait for them to be created on the api (else the episode creation will fail)
await self.post("episodes", data=episode.to_kyoo()) await self.post("episodes", data=episode.to_kyoo())
else: else:
logging.warn("Unknown video file type: %s", raw["type"]) logging.warn("Unknown video file type: %s", raw["type"])
async def create_or_get_show(self, episode: Episode) -> str: async def create_or_get_show(self, episode: Episode) -> str:
provider_id = episode.show.external_id[self.provider.name].id provider_id = episode.show.external_ids[self.provider.name].id
if provider_id in self.cache["shows"]: if provider_id in self.cache["shows"]:
ret = self.cache["shows"][provider_id] ret = self.cache["shows"][provider_id]
print(f"Waiting for {provider_id}")
await ret["event"].wait() await ret["event"].wait()
if not ret["id"]: if not ret["id"]:
raise RuntimeError("Provider failed to create the show") raise RuntimeError("Provider failed to create the show")
return ret["id"] return ret["id"]
self.cache["shows"][provider_id] = {"id": None, "event": asyncio.Event()} self.cache["shows"][provider_id] = {"id": None, "event": asyncio.Event()}
# TODO: Check if a show with the same metadata id exists already on kyoo.
show = ( show = (
await self.provider.identify_show(episode.show, language=self.languages) await self.provider.identify_show(episode.show, language=self.languages)
if isinstance(episode.show, PartialShow) if isinstance(episode.show, PartialShow)
@ -89,14 +108,20 @@ class Scanner:
# Allow tasks waiting for this show to bail out. # Allow tasks waiting for this show to bail out.
self.cache["shows"][provider_id]["event"].set() self.cache["shows"][provider_id]["event"].set()
raise raise
print(f"setting {provider_id}")
self.cache["shows"][provider_id]["id"] = ret self.cache["shows"][provider_id]["id"] = ret
self.cache["shows"][provider_id]["event"].set() self.cache["shows"][provider_id]["event"].set()
# TODO: Better handling of seasons registrations (maybe a lock also)
await self.register_seasons(ret, show.seasons)
return ret return ret
async def register_seasons(self, show_id: str, seasons: list[Season]):
for season in seasons:
season.show_id = show_id
await self.post("seasons", data=season.to_kyoo())
async def post(self, path: str, *, data: object) -> str: async def post(self, path: str, *, data: object) -> str:
url = os.environ.get("KYOO_URL", "http://back:5000") logging.debug(
logging.info(
"Sending %s: %s", "Sending %s: %s",
path, path,
jsons.dumps( jsons.dumps(
@ -106,12 +131,13 @@ class Scanner:
), ),
) )
async with self._client.post( async with self._client.post(
f"{url}/{path}", f"{self._url}/{path}",
json=data, json=data,
headers={"X-API-Key": self._api_key}, headers={"X-API-Key": self._api_key},
) as r: ) as r:
if not r.ok: # Allow 409 and continue as if it worked.
print(await r.text()) if not r.ok and r.status != 409:
r.raise_for_status() logging.error(f"Request error: {await r.text()}")
r.raise_for_status()
ret = await r.json() ret = await r.json()
return ret["id"] return ret["id"]