// 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; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace Kyoo.Postgresql { /// /// The database handle used for all local repositories. /// This is an abstract class. It is meant to be implemented by plugins. This allow the core to be database agnostic. /// /// /// It should not be used directly, to access the database use a or repositories. /// public abstract class DatabaseContext : DbContext { private readonly IHttpContextAccessor _accessor; /// /// Calculate the MD5 of a string, can only be used in database context. /// /// The string to hash /// The hash public static string MD5(string str) => throw new NotSupportedException(); public Guid? CurrentUserId => _accessor.HttpContext?.User.GetId(); /// /// All collections of Kyoo. See . /// public DbSet Collections { get; set; } /// /// All movies of Kyoo. See . /// public DbSet Movies { get; set; } /// /// All shows of Kyoo. See . /// public DbSet Shows { get; set; } /// /// All seasons of Kyoo. See . /// public DbSet Seasons { get; set; } /// /// All episodes of Kyoo. See . /// public DbSet Episodes { get; set; } /// /// All studios of Kyoo. See . /// public DbSet Studios { get; set; } /// /// The list of registered users. /// public DbSet Users { get; set; } public DbSet MovieWatchStatus { get; set; } public DbSet ShowWatchStatus { get; set; } public DbSet EpisodeWatchStatus { get; set; } public DbSet Issues { get; set; } /// /// Add a many to many link between two resources. /// /// Types are order dependant. You can't inverse the order. Please always put the owner first. /// The ID of the first resource. /// The ID of the second resource. /// The first resource type of the relation. It is the owner of the second /// The second resource type of the relation. It is the contained resource. public void AddLinks(Guid first, Guid second) where T1 : class, IResource where T2 : class, IResource { Set>(LinkName()) .Add( new Dictionary { [LinkNameFk()] = first, [LinkNameFk()] = second } ); } protected DatabaseContext(IHttpContextAccessor accessor) { _accessor = accessor; } protected DatabaseContext(DbContextOptions options, IHttpContextAccessor accessor) : base(options) { _accessor = accessor; } /// /// Get the name of the link table of the two given types. /// /// The owner type of the relation /// The child type of the relation /// The name of the table containing the links. protected abstract string LinkName() where T : IResource where T2 : IResource; /// /// Get the name of a link's foreign key. /// /// The type that will be accessible via the navigation /// The name of the foreign key for the given resource. protected abstract string LinkNameFk() where T : IResource; /// /// Set basic configurations (like preventing query tracking) /// /// An option builder to fill. protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); 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 { // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 // modelBuilder.Entity() // .OwnsOne(x => x.ExternalId, x => // { // x.ToJson(); // }); 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()); } private static void _HasImages(ModelBuilder modelBuilder) where T : class, IThumbnails { modelBuilder.Entity().OwnsOne(x => x.Poster); modelBuilder.Entity().OwnsOne(x => x.Thumbnail); modelBuilder.Entity().OwnsOne(x => x.Logo); } private static void _HasAddedDate(ModelBuilder modelBuilder) where T : class, IAddedDate { modelBuilder .Entity() .Property(x => x.AddedDate) .HasDefaultValueSql("now() at time zone 'utc'") .ValueGeneratedOnAdd(); } /// /// Create a many to many relationship between the two entities. /// The resulting relationship will have an available method. /// /// The database model builder /// The first navigation expression from T to T2 /// The second navigation expression from T2 to T /// The owning type of the relationship /// The owned type of the relationship private void _HasManyToMany( ModelBuilder modelBuilder, Expression?>> firstNavigation, Expression?>> secondNavigation ) where T : class, IResource where T2 : class, IResource { modelBuilder .Entity() .HasMany(secondNavigation) .WithMany(firstNavigation) .UsingEntity>( LinkName(), x => x.HasOne() .WithMany() .HasForeignKey(LinkNameFk()) .OnDelete(DeleteBehavior.Cascade), x => x.HasOne() .WithMany() .HasForeignKey(LinkNameFk()) .OnDelete(DeleteBehavior.Cascade) ); } /// /// Set database parameters to support every types of Kyoo. /// /// The database's model builder. protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity().Ignore(x => x.FirstEpisode).Ignore(x => x.AirDate); modelBuilder .Entity() .Ignore(x => x.PreviousEpisode) .Ignore(x => x.NextEpisode); modelBuilder .Entity() .HasMany(x => x.Seasons) .WithOne(x => x.Show) .OnDelete(DeleteBehavior.Cascade); modelBuilder .Entity() .HasMany(x => x.Episodes) .WithOne(x => x.Show) .OnDelete(DeleteBehavior.Cascade); modelBuilder .Entity() .HasMany(x => x.Episodes) .WithOne(x => x.Season) .OnDelete(DeleteBehavior.Cascade); modelBuilder .Entity() .HasOne(x => x.Studio) .WithMany(x => x.Movies) .OnDelete(DeleteBehavior.SetNull); modelBuilder .Entity() .HasOne(x => x.Studio) .WithMany(x => x.Shows) .OnDelete(DeleteBehavior.SetNull); _HasManyToMany(modelBuilder, x => x.Movies, x => x.Collections); _HasManyToMany(modelBuilder, x => x.Shows, x => x.Collections); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasMetadata(modelBuilder); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); modelBuilder .Entity() .HasKey(x => new { User = x.UserId, Movie = x.MovieId }); modelBuilder .Entity() .HasKey(x => new { User = x.UserId, Show = x.ShowId }); modelBuilder .Entity() .HasKey(x => new { User = x.UserId, Episode = x.EpisodeId }); modelBuilder .Entity() .HasOne(x => x.Movie) .WithMany(x => x.Watched) .HasForeignKey(x => x.MovieId) .OnDelete(DeleteBehavior.Cascade); modelBuilder .Entity() .HasOne(x => x.Show) .WithMany(x => x.Watched) .HasForeignKey(x => x.ShowId) .OnDelete(DeleteBehavior.Cascade); modelBuilder .Entity() .HasOne(x => x.NextEpisode) .WithMany() .HasForeignKey(x => x.NextEpisodeId) .OnDelete(DeleteBehavior.SetNull); modelBuilder .Entity() .HasOne(x => x.Episode) .WithMany(x => x.Watched) .HasForeignKey(x => x.EpisodeId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); modelBuilder .Entity() .HasQueryFilter(x => x.UserId == CurrentUserId); modelBuilder.Entity().Navigation(x => x.NextEpisode).AutoInclude(); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); modelBuilder.Entity().Ignore(x => x.WatchStatus); modelBuilder.Entity().Ignore(x => x.WatchStatus); modelBuilder.Entity().Ignore(x => x.WatchStatus); modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); modelBuilder .Entity() .HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber }) .IsUnique(); modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); modelBuilder .Entity() .HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber, x.EpisodeNumber, x.AbsoluteNumber }) .IsUnique(); modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); modelBuilder.Entity().HasIndex(x => x.Username).IsUnique(); modelBuilder.Entity().Ignore(x => x.Links); 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()); } /// /// Return a new or an in cache temporary object wih the same ID as the one given /// /// If a resource with the same ID is found in the database, it will be used. /// will be used otherwise /// The type of the resource /// A resource that is now tracked by this context. public T GetTemporaryObject(T model) where T : class, IResource { T? tmp = Set().Local.FirstOrDefault(x => x.Id == model.Id); if (tmp != null) return tmp; Entry(model).State = EntityState.Unchanged; return model; } /// /// Save changes that are applied to this context. /// /// A duplicated item has been found. /// The number of state entries written to the database. public override int SaveChanges() { try { return base.SaveChanges(); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// Indicates whether AcceptAllChanges() is called after the changes /// have been sent successfully to the database. /// A duplicated item has been found. /// The number of state entries written to the database. public override int SaveChanges(bool acceptAllChangesOnSuccess) { try { return base.SaveChanges(acceptAllChangesOnSuccess); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// Indicates whether AcceptAllChanges() is called after the changes /// have been sent successfully to the database. /// A to observe while waiting for the task to complete /// A duplicated item has been found. /// The number of state entries written to the database. public override async Task SaveChangesAsync( bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default ) { try { return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// A to observe while waiting for the task to complete /// A duplicated item has been found. /// The number of state entries written to the database. public override async Task SaveChangesAsync( CancellationToken cancellationToken = default ) { try { return await base.SaveChangesAsync(cancellationToken); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } /// /// Save changes that are applied to this context. /// /// How to retrieve the conflicting item. /// A to observe while waiting for the task to complete /// A duplicated item has been found. /// The type of the potential duplicate (this is unused). /// The number of state entries written to the database. public async Task SaveChangesAsync( Func> getExisting, CancellationToken cancellationToken = default ) { try { return await SaveChangesAsync(cancellationToken); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(await getExisting()); throw; } catch (DuplicatedItemException) { throw new DuplicatedItemException(await getExisting()); } } /// /// Save changes if no duplicates are found. If one is found, no change are saved but the current changes are no discarded. /// The current context will still hold those invalid changes. /// /// A to observe while waiting for the task to complete /// The number of state entries written to the database or -1 if a duplicate exist. public async Task SaveIfNoDuplicates(CancellationToken cancellationToken = default) { try { return await SaveChangesAsync(cancellationToken); } catch (DuplicatedItemException) { return -1; } } /// /// Return the first resource with the given slug that is currently tracked by this context. /// This allow one to limit redundant calls to during the /// same transaction and prevent fails from EF when two same entities are being tracked. /// /// The slug of the resource to check /// The type of entity to check /// The local entity representing the resource with the given slug if it exists or null. public T? LocalEntity(string slug) where T : class, IResource { return ChangeTracker.Entries().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity; } /// /// Check if the exception is a duplicated exception. /// /// The exception to check /// True if the exception is a duplicate exception. False otherwise protected abstract bool IsDuplicateException(Exception ex); /// /// Delete every changes that are on this context. /// public void DiscardChanges() { foreach ( EntityEntry entry in ChangeTracker .Entries() .Where(x => x.State != EntityState.Detached) ) { entry.State = EntityState.Detached; } } } }