diff --git a/back/Dockerfile.migrations b/back/Dockerfile.migrations index 204ef850..55377552 100644 --- a/back/Dockerfile.migrations +++ b/back/Dockerfile.migrations @@ -24,4 +24,4 @@ RUN dotnet ef migrations bundle --no-build --self-contained -r linux-${TARGETARC FROM mcr.microsoft.com/dotnet/runtime-deps:8.0 COPY --from=builder /app/migrate /app/migrate -ENTRYPOINT /app/migrate --connection "USER ID=${POSTGRES_USER};PASSWORD=${POSTGRES_PASSWORD};SERVER=${POSTGRES_SERVER};PORT=${POSTGRES_PORT};DATABASE=${POSTGRES_DB};" +ENTRYPOINT ["/app/migrate"] diff --git a/back/src/Kyoo.Abstractions/Models/INews.cs b/back/src/Kyoo.Abstractions/Models/INews.cs index 5b25eaca..b5642f5d 100644 --- a/back/src/Kyoo.Abstractions/Models/INews.cs +++ b/back/src/Kyoo.Abstractions/Models/INews.cs @@ -24,8 +24,8 @@ namespace Kyoo.Abstractions.Models; /// /// A show, a movie or a collection. /// -[OneOf(Types = new[] { typeof(Episode), typeof(Movie) })] -public interface INews : IResource, IThumbnails, IMetadata, IAddedDate, IQuery +[OneOf(Types = [typeof(Episode), typeof(Movie)])] +public interface INews : IResource, IThumbnails, IAddedDate, IQuery { static Sort IQuery.DefaultSort => new Sort.By(nameof(AddedDate), true); } diff --git a/back/src/Kyoo.Abstractions/Models/MetadataID.cs b/back/src/Kyoo.Abstractions/Models/MetadataID.cs index 37919c10..ec384ec8 100644 --- a/back/src/Kyoo.Abstractions/Models/MetadataID.cs +++ b/back/src/Kyoo.Abstractions/Models/MetadataID.cs @@ -33,3 +33,29 @@ public class MetadataId /// public string? Link { get; set; } } + +/// +/// ID informations about an episode. +/// +public class EpisodeId +{ + /// + /// The Id of the show on the metadata database. + /// + public string ShowId { get; set; } + + /// + /// The season number or null if absolute numbering is used in this database. + /// + public int? Season { get; set; } + + /// + /// The episode number or absolute number if Season is null. + /// + public int Episode { get; set; } + + /// + /// The URL of the resource on the external provider. + /// + public string? Link { get; set; } +} diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs b/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs index eb1bda94..caa6a13a 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs @@ -28,7 +28,14 @@ namespace Kyoo.Abstractions.Models; /// /// A class representing collections of . /// -public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem +public class Collection + : IQuery, + IResource, + IMetadata, + IThumbnails, + IAddedDate, + IRefreshable, + ILibraryItem { public static Sort DefaultSort => new Sort.By(nameof(Collection.Name)); @@ -76,6 +83,9 @@ public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, /// public Dictionary ExternalId { get; set; } = new(); + /// + public DateTime? NextMetadataRefresh { get; set; } + public Collection() { } [JsonConstructor] diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs index 536decaf..f32bf741 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs @@ -31,7 +31,7 @@ namespace Kyoo.Abstractions.Models; /// /// A class to represent a single show's episode. /// -public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews +public class Episode : IQuery, IResource, IThumbnails, IAddedDate, IRefreshable, INews { // Use absolute numbers by default and fallback to season/episodes if it does not exists. public static Sort DefaultSort => @@ -166,7 +166,10 @@ public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, IN public Image? Logo { get; set; } /// - public Dictionary ExternalId { get; set; } = new(); + public Dictionary ExternalId { get; set; } = []; + + /// + public DateTime? NextMetadataRefresh { get; set; } /// /// The previous episode that should be seen before viewing this one. diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IRefreshable.cs b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IRefreshable.cs new file mode 100644 index 00000000..cef2b663 --- /dev/null +++ b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IRefreshable.cs @@ -0,0 +1,29 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models; + +public interface IRefreshable +{ + /// + /// The date of the next metadata refresh. Null if auto-refresh is disabled. + /// + public DateTime? NextMetadataRefresh { get; set; } +} diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs b/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs index 4e1d0282..18d49946 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs @@ -38,6 +38,7 @@ public class Movie IMetadata, IThumbnails, IAddedDate, + IRefreshable, ILibraryItem, INews, IWatchlist @@ -134,6 +135,9 @@ public class Movie /// public Dictionary ExternalId { get; set; } = new(); + /// + public DateTime? NextMetadataRefresh { get; set; } + /// /// The ID of the Studio that made this show. /// diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Season.cs b/back/src/Kyoo.Abstractions/Models/Resources/Season.cs index 8d3e0489..d94e6514 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Season.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Season.cs @@ -31,7 +31,7 @@ namespace Kyoo.Abstractions.Models; /// /// A season of a . /// -public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate +public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, IRefreshable { public static Sort DefaultSort => new Sort.By(x => x.SeasonNumber); @@ -119,6 +119,9 @@ public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate /// public Dictionary ExternalId { get; set; } = new(); + /// + public DateTime? NextMetadataRefresh { get; set; } + /// /// The list of episodes that this season contains. /// diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs index edee8866..b3af184f 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs @@ -39,6 +39,7 @@ public class Show IOnMerge, IThumbnails, IAddedDate, + IRefreshable, ILibraryItem, IWatchlist { @@ -126,6 +127,9 @@ public class Show /// public Dictionary ExternalId { get; set; } = new(); + /// + public DateTime? NextMetadataRefresh { get; set; } + /// /// The ID of the Studio that made this show. /// diff --git a/back/src/Kyoo.Postgresql/DatabaseContext.cs b/back/src/Kyoo.Postgresql/DatabaseContext.cs index db5de9c2..31e7b40f 100644 --- a/back/src/Kyoo.Postgresql/DatabaseContext.cs +++ b/back/src/Kyoo.Postgresql/DatabaseContext.cs @@ -157,21 +157,11 @@ public abstract class DatabaseContext : DbContext optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); } - private static ValueComparer> _GetComparer() - { - return new( - (c1, c2) => c1!.SequenceEqual(c2!), - c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())) - ); - } - - /// - /// Build the metadata model for the given type. - /// - /// The database model builder - /// The type to add metadata to. - private static void _HasMetadata(ModelBuilder modelBuilder) - where T : class, IMetadata + private static void _HasJson( + ModelBuilder builder, + Expression>> property + ) + where T : class { // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 // modelBuilder.Entity() @@ -179,22 +169,33 @@ public abstract class DatabaseContext : DbContext // { // x.ToJson(); // }); - modelBuilder + builder .Entity() - .Property(x => x.ExternalId) + .Property(property) .HasConversion( v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), v => - JsonSerializer.Deserialize>( + JsonSerializer.Deserialize>( v, (JsonSerializerOptions?)null )! ) .HasColumnType("json"); - modelBuilder + builder .Entity() - .Property(x => x.ExternalId) - .Metadata.SetValueComparer(_GetComparer()); + .Property(property) + .Metadata.SetValueComparer( + new ValueComparer>( + (c1, c2) => c1!.SequenceEqual(c2!), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())) + ) + ); + } + + private static void _HasMetadata(ModelBuilder modelBuilder) + where T : class, IMetadata + { + _HasJson(modelBuilder, x => x.ExternalId); } private static void _HasImages(ModelBuilder modelBuilder) @@ -215,6 +216,17 @@ public abstract class DatabaseContext : DbContext .ValueGeneratedOnAdd(); } + private static void _HasRefreshDate(ModelBuilder builder) + where T : class, IRefreshable + { + // schedule a refresh soon since metadata can change frequently for recently added items ond online databases + builder + .Entity() + .Property(x => x.NextMetadataRefresh) + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'") + .ValueGeneratedOnAdd(); + } + /// /// Create a many to many relationship between the two entities. /// The resulting relationship will have an available method. @@ -296,8 +308,8 @@ public abstract class DatabaseContext : DbContext _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); - _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); + _HasJson(modelBuilder, x => x.ExternalId); _HasImages(modelBuilder); _HasImages(modelBuilder); @@ -313,6 +325,12 @@ public abstract class DatabaseContext : DbContext _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + modelBuilder .Entity() .HasKey(x => new { User = x.UserId, Movie = x.MovieId }); @@ -389,62 +407,9 @@ public abstract class DatabaseContext : DbContext modelBuilder.Entity().HasKey(x => new { x.Domain, x.Cause }); - // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 - // modelBuilder.Entity() - // .OwnsOne(x => x.ExternalId, x => - // { - // x.ToJson(); - // }); - modelBuilder - .Entity() - .Property(x => x.Settings) - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => - JsonSerializer.Deserialize>( - v, - (JsonSerializerOptions?)null - )! - ) - .HasColumnType("json"); - modelBuilder - .Entity() - .Property(x => x.Settings) - .Metadata.SetValueComparer(_GetComparer()); - - modelBuilder - .Entity() - .Property(x => x.ExternalId) - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => - JsonSerializer.Deserialize>( - v, - (JsonSerializerOptions?)null - )! - ) - .HasColumnType("json"); - modelBuilder - .Entity() - .Property(x => x.ExternalId) - .Metadata.SetValueComparer(_GetComparer()); - - modelBuilder - .Entity() - .Property(x => x.Extra) - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => - JsonSerializer.Deserialize>( - v, - (JsonSerializerOptions?)null - )! - ) - .HasColumnType("json"); - modelBuilder - .Entity() - .Property(x => x.Extra) - .Metadata.SetValueComparer(_GetComparer()); + _HasJson(modelBuilder, x => x.Settings); + _HasJson(modelBuilder, x => x.ExternalId); + _HasJson(modelBuilder, x => x.Extra); } /// diff --git a/back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs b/back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs new file mode 100644 index 00000000..b02a5760 --- /dev/null +++ b/back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs @@ -0,0 +1,1347 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240414212454_AddNextRefresh")] + partial class AddNextRefresh + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("date") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.cs b/back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.cs new file mode 100644 index 00000000..c1875fd6 --- /dev/null +++ b/back/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class AddNextRefresh : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "shows", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "seasons", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "movies", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "episodes", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "collections", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + // language=PostgreSQL + migrationBuilder.Sql( + """ + update episodes as e set external_id = ( + SELECT jsonb_build_object( + 'themoviedatabase', jsonb_build_object( + 'ShowId', s.external_id->'themoviedatabase'->'DataId', + 'Season', e.season_number, + 'Episode', e.episode_number, + 'Link', null + ) + ) + FROM shows AS s + WHERE s.id = e.show_id + ); + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "shows"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "seasons"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "movies"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "episodes"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "collections"); + + // language=PostgreSQL + migrationBuilder.Sql("update episodes as e set external_id = '{}';"); + } +} diff --git a/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs b/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs index a5bfe925..8968aaca 100644 --- a/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs +++ b/back/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs @@ -19,7 +19,7 @@ namespace Kyoo.Postgresql.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.3") + .HasAnnotation("ProductVersion", "8.0.4") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" }); @@ -50,6 +50,12 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("text") .HasColumnName("name"); + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + b.Property("Overview") .HasColumnType("text") .HasColumnName("overview"); @@ -100,6 +106,12 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("text") .HasColumnName("name"); + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + b.Property("Overview") .HasColumnType("text") .HasColumnName("overview"); @@ -262,6 +274,12 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("text") .HasColumnName("name"); + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + b.Property("Overview") .HasColumnType("text") .HasColumnName("overview"); @@ -386,6 +404,12 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("text") .HasColumnName("name"); + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + b.Property("Overview") .HasColumnType("text") .HasColumnName("overview"); @@ -459,6 +483,12 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("text") .HasColumnName("name"); + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + b.Property("Overview") .HasColumnType("text") .HasColumnName("overview"); diff --git a/back/src/Kyoo.Postgresql/PostgresContext.cs b/back/src/Kyoo.Postgresql/PostgresContext.cs index 3b8570f4..f907accd 100644 --- a/back/src/Kyoo.Postgresql/PostgresContext.cs +++ b/back/src/Kyoo.Postgresql/PostgresContext.cs @@ -40,6 +40,7 @@ public class PostgresContext(DbContextOptions options, IHttpContextAccessor acce { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + optionsBuilder.UseProjectables(); optionsBuilder.UseSnakeCaseNamingConvention(); base.OnConfiguring(optionsBuilder); } @@ -81,6 +82,10 @@ public class PostgresContext(DbContextOptions options, IHttpContextAccessor acce typeof(Dictionary), new JsonTypeHandler>() ); + SqlMapper.AddTypeHandler( + typeof(Dictionary), + new JsonTypeHandler>() + ); SqlMapper.AddTypeHandler( typeof(Dictionary), new JsonTypeHandler>() @@ -128,7 +133,11 @@ public class PostgresContextBuilder : IDesignTimeDbContextFactory( x => { - x.UseNpgsql(dataSource).UseProjectables(); + x.UseNpgsql(dataSource); if (builder.Environment.IsDevelopment()) x.EnableDetailedErrors().EnableSensitiveDataLogging(); }, diff --git a/scanner/matcher/__init__.py b/scanner/matcher/__init__.py index 195a1336..e7ec19da 100644 --- a/scanner/matcher/__init__.py +++ b/scanner/matcher/__init__.py @@ -13,6 +13,6 @@ async def main(): logging.getLogger("rebulk").setLevel(logging.WARNING) async with KyooClient() as kyoo, Subscriber() as sub: - provider, xem = Provider.get_all(kyoo.client) - scanner = Matcher(kyoo, provider, xem) + provider = Provider.get_default(kyoo.client) + scanner = Matcher(kyoo, provider) await sub.listen(scanner) diff --git a/scanner/matcher/matcher.py b/scanner/matcher/matcher.py index d09a4b80..bc3277c8 100644 --- a/scanner/matcher/matcher.py +++ b/scanner/matcher/matcher.py @@ -1,7 +1,6 @@ from datetime import timedelta import asyncio from logging import getLogger -from providers.implementations.thexem import TheXem from providers.provider import Provider, ProviderError from providers.types.collection import Collection from providers.types.show import Show @@ -15,10 +14,9 @@ logger = getLogger(__name__) class Matcher: - def __init__(self, client: KyooClient, provider: Provider, xem: TheXem) -> None: + def __init__(self, client: KyooClient, provider: Provider) -> None: self._client = client self._provider = provider - self._xem = xem self._collection_cache = {} self._show_cache = {} @@ -48,7 +46,7 @@ class Matcher: return True async def _identify(self, path: str): - raw = guessit(path, xem_titles=await self._xem.get_expected_titles()) + raw = guessit(path, xem_titles=await self._provider.get_expected_titles()) if "mimetype" not in raw or not raw["mimetype"].startswith("video"): return @@ -68,7 +66,7 @@ class Matcher: logger.info("Identied %s: %s", path, raw) if raw["type"] == "movie": - movie = await self._provider.identify_movie(raw["title"], raw.get("year")) + movie = await self._provider.search_movie(raw["title"], raw.get("year")) movie.path = str(path) logger.debug("Got movie: %s", movie) movie_id = await self._client.post("movies", data=movie.to_kyoo()) @@ -81,7 +79,7 @@ class Matcher: *(self._client.link_collection(x, "movie", movie_id) for x in ids) ) elif raw["type"] == "episode": - episode = await self._provider.identify_episode( + episode = await self._provider.search_episode( raw["title"], season=raw.get("season"), episode_nbr=raw.get("episode"), diff --git a/scanner/matcher/parser/guess.py b/scanner/matcher/parser/guess.py index f37f52b7..431f6f45 100644 --- a/scanner/matcher/parser/guess.py +++ b/scanner/matcher/parser/guess.py @@ -35,14 +35,14 @@ def guessit(name: str, *, xem_titles: List[str] = []): if __name__ == "__main__": import sys import json - from providers.implementations.thexem import TheXem + from providers.implementations.thexem import TheXemClient from guessit.jsonutils import GuessitEncoder from aiohttp import ClientSession import asyncio async def main(): async with ClientSession() as client: - xem = TheXem(client) + xem = TheXemClient(client) ret = guessit(sys.argv[1], xem_titles=await xem.get_expected_titles()) print(json.dumps(ret, cls=GuessitEncoder, indent=4)) diff --git a/scanner/providers/idmapper.py b/scanner/providers/idmapper.py deleted file mode 100644 index 1d9b0815..00000000 --- a/scanner/providers/idmapper.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from providers.implementations.themoviedatabase import TheMovieDatabase - -from typing import List, Optional -from providers.types.metadataid import MetadataID - - -class IdMapper: - def init(self, *, language: str, tmdb: Optional[TheMovieDatabase]): - self.language = language - self._tmdb = tmdb - - async def get_show( - self, show: dict[str, MetadataID], *, required: Optional[List[str]] = None - ): - ids = show - - # Only fetch using tmdb if one of the required ids is not already known. - should_fetch = required is not None and any((x not in ids for x in required)) - if self._tmdb and self._tmdb.name in ids and should_fetch: - tmdb_info = await self._tmdb.identify_show(ids[self._tmdb.name].data_id) - return {**ids, **tmdb_info.external_id} - return ids - - async def get_movie( - self, movie: dict[str, MetadataID], *, required: Optional[List[str]] = None - ): - # TODO: actually do something here - return movie diff --git a/scanner/providers/implementations/themoviedatabase.py b/scanner/providers/implementations/themoviedatabase.py index 3f1c861e..92382329 100644 --- a/scanner/providers/implementations/themoviedatabase.py +++ b/scanner/providers/implementations/themoviedatabase.py @@ -5,15 +5,13 @@ from logging import getLogger from typing import Awaitable, Callable, Dict, List, Optional, Any, TypeVar from itertools import accumulate, zip_longest -from providers.idmapper import IdMapper -from providers.implementations.thexem import TheXem from providers.utils import ProviderError from matcher.cache import cache from ..provider import Provider from ..types.movie import Movie, MovieTranslation, Status as MovieStatus from ..types.season import Season, SeasonTranslation -from ..types.episode import Episode, EpisodeTranslation, PartialShow +from ..types.episode import Episode, EpisodeTranslation, PartialShow, EpisodeID from ..types.studio import Studio from ..types.genre import Genre from ..types.metadataid import MetadataID @@ -29,14 +27,10 @@ class TheMovieDatabase(Provider): languages: list[str], client: ClientSession, api_key: str, - xem: TheXem, - idmapper: IdMapper, ) -> None: super().__init__() self._languages = languages self._client = client - self._xem = xem - self._idmapper = idmapper self.base = "https://api.themoviedb.org/3" self.api_key = api_key self.genre_map = { @@ -142,15 +136,17 @@ class TheMovieDatabase(Provider): }, ) - async def identify_movie(self, name: str, year: Optional[int]) -> Movie: + async def search_movie(self, name: str, year: Optional[int]) -> Movie: search_results = ( await self.get("search/movie", params={"query": name, "year": year}) )["results"] if len(search_results) == 0: raise ProviderError(f"No result for a movie named: {name}") search = self.get_best_result(search_results, name, year) - movie_id = search["id"] - languages = self.get_languages(search["original_language"]) + return await self.identify_movie(search["id"]) + + async def identify_movie(self, movie_id: str) -> Movie: + languages = self.get_languages() async def for_language(lng: str) -> Movie: movie = await self.get( @@ -216,7 +212,7 @@ class TheMovieDatabase(Provider): movie["images"]["posters"] + ( [{"file_path": movie["poster_path"]}] - if lng == search["original_language"] + if lng == movie["original_language"] else [] ) ), @@ -225,7 +221,7 @@ class TheMovieDatabase(Provider): movie["images"]["backdrops"] + ( [{"file_path": movie["backdrop_path"]}] - if lng == search["original_language"] + if lng == movie["original_language"] else [] ) ), @@ -239,8 +235,13 @@ class TheMovieDatabase(Provider): return ret ret = await self.process_translations(for_language, languages) - # If we have more external_ids freely available, add them. - ret.external_id = await self._idmapper.get_movie(ret.external_id) + if ( + ret.original_language is not None + and ret.original_language not in ret.translations + ): + ret.translations[ret.original_language] = ( + await for_language(ret.original_language) + ).translations[ret.original_language] return ret @cache(ttl=timedelta(days=1)) @@ -362,8 +363,6 @@ class TheMovieDatabase(Provider): ret.translations[ret.original_language] = ( await for_language(ret.original_language) ).translations[ret.original_language] - # If we have more external_ids freely available, add them. - ret.external_id = await self._idmapper.get_show(ret.external_id) return ret def to_season( @@ -396,13 +395,13 @@ class TheMovieDatabase(Provider): }, ) - async def identify_season(self, show_id: str, season_number: int) -> Season: + async def identify_season(self, show_id: str, season: int) -> Season: # We already get seasons info in the identify_show and chances are this gets cached already show = await self.identify_show(show_id) - ret = next((x for x in show.seasons if x.season_number == season_number), None) + ret = next((x for x in show.seasons if x.season_number == season), None) if ret is None: raise ProviderError( - f"Could not find season {season_number} for show {show.to_kyoo()['name']}" + f"Could not find season {season} for show {show.to_kyoo()['name']}" ) return ret @@ -413,29 +412,7 @@ class TheMovieDatabase(Provider): )["results"] if len(search_results) == 0: - (new_name, tvdbid) = await self._xem.get_show_override("tvdb", name) - if new_name is None or tvdbid is None or name.lower() == new_name.lower(): - raise ProviderError(f"No result for a tv show named: {name}") - ret = PartialShow( - name=new_name, - original_language=None, - external_id={ - "tvdb": MetadataID(tvdbid, link=None), - }, - ) - ret.external_id = await self._idmapper.get_show( - ret.external_id, required=[self.name] - ) - - if self.name in ret.external_id: - return ret - logger.warn( - "Could not map xem exception to themoviedb, searching instead for %s", - new_name, - ) - nret = await self.search_show(new_name, year) - nret.external_id = {**ret.external_id, **nret.external_id} - return nret + raise ProviderError(f"No result for a tv show named: {name}") search = self.get_best_result(search_results, name, year) show_id = search["id"] @@ -449,7 +426,7 @@ class TheMovieDatabase(Provider): }, ) - async def identify_episode( + async def search_episode( self, name: str, season: Optional[int], @@ -458,39 +435,8 @@ class TheMovieDatabase(Provider): year: Optional[int], ) -> Episode: show = await self.search_show(name, year) - languages = self.get_languages(show.original_language) - # Keep it for xem overrides of season/episode - old_name = name - name = show.name show_id = show.external_id[self.name].data_id - # Handle weird season names overrides from thexem. - # For example when name is "Jojo's bizzare adventure - Stone Ocean", with season None, - # We want something like season 6 ep 3. - if season is None and absolute is not None: - ids = await self._idmapper.get_show(show.external_id, required=["tvdb"]) - tvdb_id = ( - ids["tvdb"].data_id - if "tvdb" in ids and ids["tvdb"] is not None - else None - ) - if tvdb_id is None: - logger.info( - "Tvdb could not be found, trying xem name lookup for %s", name - ) - _, tvdb_id = await self._xem.get_show_override("tvdb", old_name) - if tvdb_id is not None: - ( - tvdb_season, - tvdb_episode, - absolute, - ) = await self._xem.get_episode_override( - "tvdb", tvdb_id, old_name, absolute - ) - # Most of the time, tvdb absolute and tmdb absolute are in think so we use that as our souce of truth. - # tvdb_season/episode are not in sync with tmdb so we discard those and use our usual absolute order fetching. - (_, _) = tvdb_season, tvdb_episode - if absolute is not None and (season is None or episode_nbr is None): (season, episode_nbr) = await self.get_episode_from_absolute( show_id, absolute @@ -498,12 +444,16 @@ class TheMovieDatabase(Provider): if season is None or episode_nbr is None: raise ProviderError( - f"Could not guess season or episode number of the episode {name} {season}-{episode_nbr} ({absolute})", + f"Could not guess season or episode number of the episode {show.name} {season}-{episode_nbr} ({absolute})", ) if absolute is None: absolute = await self.get_absolute_number(show_id, season, episode_nbr) + return await self.identify_episode(show_id, season, episode_nbr, absolute) + async def identify_episode( + self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int + ) -> Episode: async def for_language(lng: str) -> Episode: try: episode = await self.get( @@ -518,12 +468,20 @@ class TheMovieDatabase(Provider): params={ "language": lng, }, - not_found_fail=f"Could not find episode {episode_nbr} of season {season} of serie {name} (absolute: {absolute})", + not_found_fail=f"Could not find episode {episode_nbr} of season {season} of serie {show_id} (absolute: {absolute})", ) logger.debug("TMDb responded: %s", episode) ret = Episode( - show=show, + show=PartialShow( + name=show_id, + original_language=None, + external_id={ + self.name: MetadataID( + show_id, f"https://www.themoviedb.org/tv/{show_id}" + ) + }, + ), season_number=episode["season_number"], episode_number=episode["episode_number"], absolute_number=absolute, @@ -537,8 +495,10 @@ class TheMovieDatabase(Provider): if "still_path" in episode and episode["still_path"] is not None else None, external_id={ - self.name: MetadataID( - episode["id"], + self.name: EpisodeID( + show_id, + episode["season_number"], + episode["episode_number"], f"https://www.themoviedb.org/tv/{show_id}/season/{episode['season_number']}/episode/{episode['episode_number']}", ), }, @@ -550,7 +510,7 @@ class TheMovieDatabase(Provider): ret.translations = {lng: translation} return ret - return await self.process_translations(for_language, languages) + return await self.process_translations(for_language, self.get_languages()) def get_best_result( self, search_results: List[Any], name: str, year: Optional[int] @@ -654,7 +614,9 @@ class TheMovieDatabase(Provider): (seasons_nbrs[0], absolute), ) - async def get_absolute_number(self, show_id: str, season: int, episode_nbr: int): + async def get_absolute_number( + self, show_id: str, season: int, episode_nbr: int + ) -> int: absgrp = await self.get_absolute_order(show_id) if absgrp is None: # We assume that each season should be played in order with no special episodes. @@ -684,7 +646,9 @@ class TheMovieDatabase(Provider): (x["episode_number"] for x in absgrp if x["season_number"] == season), None ) if start is None or start <= episode_nbr: - return None + raise ProviderError( + f"Could not guess absolute number of episode {show_id} s{season} e{episode_nbr}" + ) # add back the continuous number (imagine the user has one piece S21e31 # but tmdb registered it as S21E831 since S21's first ep is 800 return await self.get_absolute_number(show_id, season, episode_nbr + start) diff --git a/scanner/providers/implementations/thexem.py b/scanner/providers/implementations/thexem.py index 23e22c84..744ff566 100644 --- a/scanner/providers/implementations/thexem.py +++ b/scanner/providers/implementations/thexem.py @@ -3,8 +3,15 @@ from typing import Dict, List, Literal from aiohttp import ClientSession from logging import getLogger from datetime import timedelta +from typing import Optional -from providers.utils import ProviderError +from ..provider import Provider +from ..utils import ProviderError +from ..types.collection import Collection +from ..types.movie import Movie +from ..types.show import Show +from ..types.season import Season +from ..types.episode import Episode from matcher.cache import cache logger = getLogger(__name__) @@ -21,7 +28,7 @@ def clean(s: str): return s -class TheXem: +class TheXemClient: def __init__(self, client: ClientSession) -> None: self._client = client self.base = "https://thexem.info" @@ -155,3 +162,77 @@ class TheXem: for y in x[1:]: titles.extend(clean(name) for name in y.keys()) return titles + + +class TheXem(Provider): + def __init__(self, client: ClientSession, base: Provider) -> None: + super().__init__() + self._client = TheXemClient(client) + self._base = base + + @property + def name(self) -> str: + # Use the base name for id lookup on the matcher. + return self._base.name + + async def get_expected_titles(self) -> list[str]: + return await self._client.get_expected_titles() + + async def search_movie(self, name: str, year: Optional[int]) -> Movie: + return await self._base.search_movie(name, year) + + async def search_episode( + self, + name: str, + season: Optional[int], + episode_nbr: Optional[int], + absolute: Optional[int], + year: Optional[int], + ) -> Episode: + """ + Handle weird season names overrides from thexem. + For example when name is "Jojo's bizzare adventure - Stone Ocean", with season None, + We want something like season 6 ep 3. + """ + new_name, tvdb_id = await self._client.get_show_override("tvdb", name) + + if new_name is None: + return await self._base.search_episode( + name, season, episode_nbr, absolute, year + ) + + if season is None and absolute is not None: + if tvdb_id is not None: + ( + tvdb_season, + tvdb_episode, + absolute, + ) = await self._client.get_episode_override( + "tvdb", tvdb_id, name, absolute + ) + # Most of the time, tvdb absolute and tmdb absolute are in sync so we use that as our souce of truth. + # tvdb_season/episode are not in sync with tmdb so we discard those and use our usual absolute order fetching. + if self._base == "tvdb": + return await self._base.search_episode( + new_name, tvdb_season, tvdb_episode, absolute, year + ) + return await self._base.search_episode( + new_name, season, episode_nbr, absolute, year + ) + + async def identify_movie(self, movie_id: str) -> Movie: + return await self._base.identify_movie(movie_id) + + async def identify_show(self, show_id: str) -> Show: + return await self._base.identify_show(show_id) + + async def identify_season(self, show_id: str, season: int) -> Season: + return await self._base.identify_season(show_id, season) + + async def identify_episode( + self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int + ) -> Episode: + return await self._base.identify_episode(show_id, season, episode_nbr, absolute) + + async def identify_collection(self, provider_id: str) -> Collection: + return await self._base.identify_collection(provider_id) diff --git a/scanner/providers/provider.py b/scanner/providers/provider.py index 5f5e7f53..ab25bf85 100644 --- a/scanner/providers/provider.py +++ b/scanner/providers/provider.py @@ -1,9 +1,8 @@ import os from aiohttp import ClientSession from abc import abstractmethod, abstractproperty -from typing import Optional, Self +from typing import Optional -from providers.implementations.thexem import TheXem from providers.utils import ProviderError from .types.show import Show @@ -15,7 +14,7 @@ from .types.collection import Collection class Provider: @classmethod - def get_all(cls, client: ClientSession) -> tuple[Self, TheXem]: + def get_default(cls, client: ClientSession): languages = os.environ.get("LIBRARY_LANGUAGES") if not languages: print("Missing environment variable 'LIBRARY_LANGUAGES'.") @@ -23,47 +22,33 @@ class Provider: languages = languages.split(",") providers = [] - from providers.idmapper import IdMapper - - idmapper = IdMapper() - xem = TheXem(client) - from providers.implementations.themoviedatabase import TheMovieDatabase tmdb = os.environ.get("THEMOVIEDB_APIKEY") if tmdb: - tmdb = TheMovieDatabase(languages, client, tmdb, xem, idmapper) + tmdb = TheMovieDatabase(languages, client, tmdb) providers.append(tmdb) - else: - tmdb = None if not any(providers): raise ProviderError( "No provider configured. You probably forgot to specify an API Key" ) - idmapper.init(tmdb=tmdb, language=languages[0]) + from providers.implementations.thexem import TheXem - return next(iter(providers)), xem + provider = next(iter(providers)) + return TheXem(client, provider) @abstractproperty def name(self) -> str: raise NotImplementedError @abstractmethod - async def identify_movie(self, name: str, year: Optional[int]) -> Movie: + async def search_movie(self, name: str, year: Optional[int]) -> Movie: raise NotImplementedError @abstractmethod - async def identify_show(self, show_id: str) -> Show: - raise NotImplementedError - - @abstractmethod - async def identify_season(self, show_id: str, season_number: int) -> Season: - raise NotImplementedError - - @abstractmethod - async def identify_episode( + async def search_episode( self, name: str, season: Optional[int], @@ -73,6 +58,28 @@ class Provider: ) -> Episode: raise NotImplementedError + @abstractmethod + async def identify_movie(self, movie_id: str) -> Movie: + raise NotImplementedError + + @abstractmethod + async def identify_show(self, show_id: str) -> Show: + raise NotImplementedError + + @abstractmethod + async def identify_season(self, show_id: str, season: int) -> Season: + raise NotImplementedError + + @abstractmethod + async def identify_episode( + self, show_id: str, season: Optional[int], episode_nbr: int, absolute: int + ) -> Episode: + raise NotImplementedError + @abstractmethod async def identify_collection(self, provider_id: str) -> Collection: raise NotImplementedError + + @abstractmethod + async def get_expected_titles(self) -> list[str]: + return [] diff --git a/scanner/providers/types/episode.py b/scanner/providers/types/episode.py index 87fae03a..0401d4ee 100644 --- a/scanner/providers/types/episode.py +++ b/scanner/providers/types/episode.py @@ -14,6 +14,14 @@ class PartialShow: external_id: dict[str, MetadataID] +@dataclass +class EpisodeID: + show_id: str + season: Optional[int] + episode: int + link: str + + @dataclass class EpisodeTranslation: name: str @@ -23,13 +31,13 @@ class EpisodeTranslation: @dataclass class Episode: show: Show | PartialShow - season_number: Optional[int] - episode_number: Optional[int] - absolute_number: Optional[int] + season_number: int + episode_number: int + absolute_number: int runtime: Optional[int] release_date: Optional[date | int] thumbnail: Optional[str] - external_id: dict[str, MetadataID] + external_id: dict[str, EpisodeID] path: Optional[str] = None show_id: Optional[str] = None diff --git a/transcoder/Dockerfile b/transcoder/Dockerfile index a316cba8..0481e850 100644 --- a/transcoder/Dockerfile +++ b/transcoder/Dockerfile @@ -59,4 +59,4 @@ ENV NVIDIA_VISIBLE_DEVICES="all" ENV NVIDIA_DRIVER_CAPABILITIES="all" EXPOSE 7666 -CMD ./transcoder +CMD ["./transcoder"] diff --git a/transcoder/Dockerfile.dev b/transcoder/Dockerfile.dev index efadc4e8..ecf1daaa 100644 --- a/transcoder/Dockerfile.dev +++ b/transcoder/Dockerfile.dev @@ -47,4 +47,4 @@ ENV NVIDIA_VISIBLE_DEVICES="all" ENV NVIDIA_DRIVER_CAPABILITIES="all" EXPOSE 7666 -CMD wgo run -race . +CMD ["wgo", "run", "-race", "."]