// 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.ComponentModel.DataAnnotations.Schema;
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 Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.ValueGeneration;
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
{
///
/// 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();
///
/// 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 people of Kyoo. See .
// ///
// public DbSet People { get; set; }
///
/// All studios of Kyoo. See .
///
public DbSet Studios { get; set; }
///
/// The list of registered users.
///
public DbSet Users { get; set; }
// ///
// /// All people's role. See .
// ///
// public DbSet PeopleRoles { 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
});
}
///
/// The default constructor
///
protected DatabaseContext() { }
///
/// Create a new using specific options
///
/// The options to use.
protected DatabaseContext(DbContextOptions options)
: base(options)
{ }
///
/// 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);
}
///
/// 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.ExternalIDs, x =>
// {
// x.ToJson();
// });
modelBuilder.Entity()
.Property(x => x.ExternalId)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)!
)
.HasColumnType("json");
}
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'");
}
///
/// 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()
// .Ignore(x => x.ForPeople);
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);
_HasMetadata(modelBuilder);
_HasImages(modelBuilder);
_HasImages(modelBuilder);
_HasImages(modelBuilder);
_HasImages(modelBuilder);
// _HasImages(modelBuilder);
_HasImages(modelBuilder);
_HasAddedDate(modelBuilder);
_HasAddedDate(modelBuilder);
_HasAddedDate(modelBuilder);
_HasAddedDate(modelBuilder);
_HasAddedDate(modelBuilder);
_HasAddedDate(modelBuilder);
modelBuilder.Entity().OwnsOne(x => x.Logo);
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 => 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()
.Ignore(x => x.Links);
}
///
/// 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;
}
}
}
}