// 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;
}
}
}