// 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.Models; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace Kyoo.Postgresql; 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(); public DbSet Collections { get; set; } public DbSet Movies { get; set; } public DbSet Shows { get; set; } public DbSet Seasons { get; set; } public DbSet Episodes { get; set; } public DbSet Studios { get; set; } 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; } protected abstract string LinkName() where T : IResource where T2 : IResource; protected abstract string LinkNameFk() where T : IResource; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); } private static void _HasJson( ModelBuilder builder, Expression>> property ) where T : class { // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 // modelBuilder.Entity() // .OwnsOne(x => x.ExternalId, x => // { // x.ToJson(); // }); builder .Entity() .Property(property) .HasConversion( v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), v => JsonSerializer.Deserialize>( v, (JsonSerializerOptions?)null )! ) .HasColumnType("json"); builder .Entity() .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) where T : class, IThumbnails { modelBuilder.Entity().OwnsOne(x => x.Poster, x => x.ToJson()); modelBuilder.Entity().OwnsOne(x => x.Thumbnail, x => x.ToJson()); modelBuilder.Entity().OwnsOne(x => x.Logo, x => x.ToJson()); } private static void _HasAddedDate(ModelBuilder modelBuilder) where T : class, IAddedDate { modelBuilder .Entity() .Property(x => x.AddedDate) .HasDefaultValueSql("now() at time zone 'utc'") .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(); } 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) ); } 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); _HasJson(modelBuilder, x => x.ExternalId); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasImages(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _HasAddedDate(modelBuilder); _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 }); 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 }); _HasJson(modelBuilder, x => x.Settings); _HasJson(modelBuilder, x => x.ExternalId); _HasJson(modelBuilder, x => x.Extra); } public override int SaveChanges() { try { return base.SaveChanges(); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } public override int SaveChanges(bool acceptAllChangesOnSuccess) { try { return base.SaveChanges(acceptAllChangesOnSuccess); } catch (DbUpdateException ex) { DiscardChanges(); if (IsDuplicateException(ex)) throw new DuplicatedItemException(); throw; } } 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; } } 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; } } 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()); } } public async Task SaveIfNoDuplicates(CancellationToken cancellationToken = default) { try { return await SaveChangesAsync(cancellationToken); } catch (DuplicatedItemException) { return -1; } } public T? LocalEntity(string slug) where T : class, IResource { return ChangeTracker.Entries().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity; } protected abstract bool IsDuplicateException(Exception ex); public void DiscardChanges() { foreach ( EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Detached) ) { entry.State = EntityState.Detached; } } }