Rework images storing (#428)

This commit is contained in:
Zoe Roux 2024-04-21 21:57:19 +02:00 committed by GitHub
commit 41130cab1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 2674 additions and 1561 deletions

View File

@ -75,5 +75,6 @@ MEILI_HOST="http://meilisearch:7700"
MEILI_MASTER_KEY="ghvjkgisbgkbgskegblfqbgjkebbhgwkjfb"
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_DEFAULT_USER=kyoo
RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha

View File

@ -46,6 +46,7 @@ def main():
connection = pika.BlockingConnection(
pika.ConnectionParameters(
host=os.environ.get("RABBITMQ_HOST", "rabbitmq"),
port=os.environ.get("RABBITMQ_PORT", 5672),
credentials=pika.credentials.PlainCredentials(
os.environ.get("RABBITMQ_DEFAULT_USER", "guest"),
os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"),

View File

@ -56,7 +56,7 @@ class Simkl(Service):
]
},
headers={
"Authorization": f"Bearer {user.external_id["simkl"].token.access_token}",
"Authorization": f"Bearer {user.external_id['simkl'].token.access_token}",
"simkl-api-key": self._api_key,
},
)
@ -85,7 +85,7 @@ class Simkl(Service):
]
},
headers={
"Authorization": f"Bearer {user.external_id["simkl"].token.access_token}",
"Authorization": f"Bearer {user.external_id['simkl'].token.access_token}",
"simkl-api-key": self._api_key,
},
)

View File

@ -22,7 +22,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:8.0
RUN apt-get update && apt-get install -y curl
COPY --from=builder /app /app
WORKDIR /kyoo
WORKDIR /app
EXPOSE 5000
# The back can take a long time to start if meilisearch is initializing
HEALTHCHECK --interval=5s --retries=15 CMD curl --fail http://localhost:5000/health || exit

View File

@ -14,7 +14,7 @@ COPY src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj src/Kyoo.RabbitMq/Kyoo.RabbitMq.cspr
COPY src/Kyoo.Swagger/Kyoo.Swagger.csproj src/Kyoo.Swagger/Kyoo.Swagger.csproj
RUN dotnet restore
WORKDIR /kyoo
WORKDIR /app
EXPOSE 5000
ENV DOTNET_USE_POLLING_FILE_WATCHER 1
# HEALTHCHECK --interval=5s CMD curl --fail http://localhost:5000/health || exit

View File

@ -19,7 +19,10 @@ RUN dotnet restore -a $TARGETARCH
COPY . .
RUN dotnet build
RUN dotnet ef migrations bundle --no-build --self-contained -r linux-${TARGETARCH} -f -o /app/migrate -p src/Kyoo.Postgresql --verbose
RUN dotnet ef migrations bundle \
--msbuildprojectextensionspath out/obj/Kyoo.Postgresql \
--no-build --self-contained -r linux-${TARGETARCH} -f \
-o /app/migrate -p src/Kyoo.Postgresql --verbose
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0
COPY --from=builder /app/migrate /app/migrate

View File

@ -28,6 +28,11 @@
<CheckCodingStyle Condition="$(CheckCodingStyle) == ''">true</CheckCodingStyle>
</PropertyGroup>
<PropertyGroup>
<BaseIntermediateOutputPath>$(MsBuildThisFileDirectory)/../out/obj/$(MSBuildProjectName)</BaseIntermediateOutputPath>
<BaseOutputPath>$(MsBuildThisFileDirectory)/../out/bin/$(MSBuildProjectName)</BaseOutputPath>
</PropertyGroup>
<ItemGroup Condition="$(CheckCodingStyle) == true">
<None Include="$(MSBuildThisFileDirectory)../.editorconfig" Link=".editorconfig" Visible="false" />
</ItemGroup>

View File

@ -23,56 +23,19 @@ using Kyoo.Abstractions.Models;
namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// Download images and retrieve the path of those images for a resource.
/// </summary>
public interface IThumbnailsManager
{
/// <summary>
/// Download images of a specified item.
/// If no images is available to download, do nothing and silently return.
/// </summary>
/// <param name="item">
/// The item to cache images.
/// </param>
/// <typeparam name="T">The type of the item</typeparam>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DownloadImages<T>(T item)
where T : IThumbnails;
/// <summary>
/// Retrieve the local path of an image of the given item.
/// </summary>
/// <param name="item">The item to retrieve the poster from.</param>
/// <param name="image">The ID of the image.</param>
/// <param name="quality">The quality of the image</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <returns>The path of the image for the given resource or null if it does not exists.</returns>
string GetImagePath<T>(T item, string image, ImageQuality quality)
where T : IThumbnails;
Task DownloadImage(Image? image, string what);
string GetImagePath(Guid imageId, ImageQuality quality);
/// <summary>
/// Delete images associated with the item.
/// </summary>
/// <param name="item">
/// The item with cached images.
/// </param>
/// <typeparam name="T">The type of the item</typeparam>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteImages<T>(T item)
where T : IThumbnails;
/// <summary>
/// Set the user's profile picture
/// </summary>
/// <param name="userId">The id of the user. </param>
/// <returns>The byte stream of the image. Null if no image exist.</returns>
Task<Stream> GetUserImage(Guid userId);
/// <summary>
/// Set the user's profile picture
/// </summary>
/// <param name="userId">The id of the user. </param>
/// <param name="image">The byte stream of the image. Null to delete the image.</param>
Task SetUserImage(Guid userId, Stream? image);
}

View File

@ -94,7 +94,7 @@ public class PermissionAttribute : Attribute, IFilterFactory
/// <summary>
/// The group of this permission.
/// </summary>
public Group Group { get; }
public Group Group { get; set; }
/// <summary>
/// Ask a permission to run an action.

View File

@ -17,12 +17,9 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models;
@ -49,9 +46,13 @@ public interface IThumbnails
}
[JsonConverter(typeof(ImageConvertor))]
[SqlFirstColumn(nameof(Source))]
public class Image
{
/// <summary>
/// A unique identifier for the image. Used for proper http caches.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The original image from another server.
/// </summary>
@ -63,6 +64,21 @@ public class Image
[MaxLength(32)]
public string Blurhash { get; set; }
/// <summary>
/// The url to access the image in low quality.
/// </summary>
public string Low => $"/thumbnails/{Id}?quality=low";
/// <summary>
/// The url to access the image in medium quality.
/// </summary>
public string Medium => $"/thumbnails/{Id}?quality=medium";
/// <summary>
/// The url to access the image in high quality.
/// </summary>
public string High => $"/thumbnails/{Id}?quality=high";
public Image() { }
[JsonConstructor]
@ -72,6 +88,7 @@ public class Image
Blurhash = blurhash ?? "000000";
}
//
public class ImageConvertor : JsonConverter<Image>
{
/// <inheritdoc />
@ -84,7 +101,10 @@ public class Image
if (reader.TokenType == JsonTokenType.String && reader.GetString() is string source)
return new Image(source);
using JsonDocument document = JsonDocument.ParseValue(ref reader);
return document.RootElement.Deserialize<Image>();
string? src = document.RootElement.GetProperty("Source").GetString();
string? blurhash = document.RootElement.GetProperty("Blurhash").GetString();
Guid? id = document.RootElement.GetProperty("Id").GetGuid();
return new Image(src ?? string.Empty, blurhash) { Id = id ?? Guid.Empty };
}
/// <inheritdoc />
@ -97,6 +117,9 @@ public class Image
writer.WriteStartObject();
writer.WriteString("source", value.Source);
writer.WriteString("blurhash", value.Blurhash);
writer.WriteString("low", value.Low);
writer.WriteString("medium", value.Medium);
writer.WriteString("high", value.High);
writer.WriteEndObject();
}
}

View File

@ -56,4 +56,5 @@ public static class Constants
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
/// </summary>
public const string AdminGroup = "4:Admin";
public const string OtherGroup = "5:Other";
}

View File

@ -1,70 +0,0 @@
// 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 <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
namespace Kyoo.Utils;
/// <summary>
/// A set of extensions class for enumerable.
/// </summary>
public static class EnumerableExtensions
{
/// <summary>
/// If the enumerable is empty, execute an action.
/// </summary>
/// <param name="self">The enumerable to check</param>
/// <param name="action">The action to execute is the list is empty</param>
/// <typeparam name="T">The type of items inside the list</typeparam>
/// <returns>The iterator proxied, there is no dual iterations.</returns>
public static IEnumerable<T> IfEmpty<T>(this IEnumerable<T> self, Action action)
{
static IEnumerable<T> Generator(IEnumerable<T> self, Action action)
{
using IEnumerator<T> enumerator = self.GetEnumerator();
if (!enumerator.MoveNext())
{
action();
yield break;
}
do
{
yield return enumerator.Current;
} while (enumerator.MoveNext());
}
return Generator(self, action);
}
/// <summary>
/// A foreach used as a function with a little specificity: the list can be null.
/// </summary>
/// <param name="self">The list to enumerate. If this is null, the function result in a no-op</param>
/// <param name="action">The action to execute for each arguments</param>
/// <typeparam name="T">The type of items in the list</typeparam>
public static void ForEach<T>(this IEnumerable<T>? self, Action<T> action)
{
if (self == null)
return;
foreach (T i in self)
action(i);
}
}

View File

@ -25,7 +25,6 @@ using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes;
using Microsoft.AspNetCore.Http;
using static System.Text.Json.JsonNamingPolicy;
namespace Kyoo.Utils;

View File

@ -1,133 +0,0 @@
// 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 <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Utils;
/// <summary>
/// A class containing helper methods to merge objects.
/// </summary>
public static class Merger
{
/// <summary>
/// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept.
/// </summary>
/// <param name="first">The first dictionary to merge</param>
/// <param name="second">The second dictionary to merge</param>
/// <param name="hasChanged">
/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
/// <returns>
/// A dictionary with the missing elements of <paramref name="second"/>
/// set to those of <paramref name="first"/>.
/// </returns>
public static IDictionary<T, T2>? CompleteDictionaries<T, T2>(
IDictionary<T, T2>? first,
IDictionary<T, T2>? second,
out bool hasChanged
)
{
if (first == null)
{
hasChanged = true;
return second;
}
hasChanged = false;
if (second == null)
return first;
hasChanged = second.Any(x =>
!first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false
);
foreach ((T key, T2 value) in first)
second.TryAdd(key, value);
return second;
}
/// <summary>
/// Set every non-default values of seconds to the corresponding property of second.
/// Dictionaries are handled like anonymous objects with a property per key/pair value
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
/// </summary>
/// <example>
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"}
/// </example>
/// <param name="first">
/// The object to complete
/// </param>
/// <param name="second">
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
/// </param>
/// <param name="where">
/// Filter fields that will be merged
/// </param>
/// <typeparam name="T">Fields of T will be completed</typeparam>
/// <returns><paramref name="first"/></returns>
public static T Complete<T>(T first, T? second, Func<PropertyInfo, bool>? where = null)
{
if (second == null)
return first;
Type type = typeof(T);
IEnumerable<PropertyInfo> properties = type.GetProperties()
.Where(x =>
x is { CanRead: true, CanWrite: true }
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null
);
if (where != null)
properties = properties.Where(where);
foreach (PropertyInfo property in properties)
{
object? value = property.GetValue(second);
if (value?.Equals(property.GetValue(first)) == true)
continue;
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{
Type[] dictionaryTypes = Utility
.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))!
.GenericTypeArguments;
object?[] parameters = { property.GetValue(first), value, false };
object newDictionary = Utility.RunGenericMethod<object>(
typeof(Merger),
nameof(CompleteDictionaries),
dictionaryTypes,
parameters
)!;
if ((bool)parameters[2]!)
property.SetValue(first, newDictionary);
}
else
property.SetValue(first, value);
}
if (first is IOnMerge merge)
merge.OnMerge(second);
return first;
}
}

View File

@ -177,25 +177,6 @@ public static class Utility
yield return type;
}
/// <summary>
/// Check if <paramref name="type"/> inherit from a generic type <paramref name="genericType"/>.
/// </summary>
/// <param name="type">The type to check</param>
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable&lt;&gt;).</param>
/// <returns>True if obj inherit from genericType. False otherwise</returns>
public static bool IsOfGenericType(Type type, Type genericType)
{
if (!genericType.IsGenericType)
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
IEnumerable<Type> types = genericType.IsInterface
? type.GetInterfaces()
: type.GetInheritanceTree();
return types
.Prepend(type)
.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
}
/// <summary>
/// Get the generic definition of <paramref name="genericType"/>.
/// For example, calling this function with List&lt;string&gt; and typeof(IEnumerable&lt;&gt;) will return IEnumerable&lt;string&gt;
@ -217,147 +198,6 @@ public static class Utility
.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
}
/// <summary>
/// Retrieve a method from an <see cref="Type"/> with the given name and respect the
/// amount of parameters and generic parameters. This works for polymorphic methods.
/// </summary>
/// <param name="type">
/// The type owning the method. For non static methods, this is the <c>this</c>.
/// </param>
/// <param name="flag">
/// The binding flags of the method. This allow you to specify public/private and so on.
/// </param>
/// <param name="name">
/// The name of the method.
/// </param>
/// <param name="generics">
/// The list of generic parameters.
/// </param>
/// <param name="args">
/// The list of parameters.
/// </param>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The method handle of the matching method.</returns>
public static MethodInfo GetMethod(
Type type,
BindingFlags flag,
string name,
Type[] generics,
object?[] args
)
{
MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public)
.Where(x => x.Name == name)
.Where(x => x.GetGenericArguments().Length == generics.Length)
.Where(x => x.GetParameters().Length == args.Length)
.IfEmpty(() =>
{
throw new ArgumentException(
$"A method named {name} with "
+ $"{args.Length} arguments and {generics.Length} generic "
+ $"types could not be found on {type.Name}."
);
})
// TODO this won't work but I don't know why.
// .Where(x =>
// {
// int i = 0;
// return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++]));
// })
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified."))
// TODO this won't work for Type<T> because T is specified in arguments but not in the parameters type.
// .Where(x =>
// {
// int i = 0;
// return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++]));
// })
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types."))
.Take(2)
.ToArray();
if (methods.Length == 1)
return methods[0];
throw new ArgumentException(
$"Multiple methods named {name} match the generics and parameters constraints."
);
}
/// <summary>
/// Run a generic static method for a runtime <see cref="Type"/>.
/// </summary>
/// <example>
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
/// you could do:
/// <code lang="C#">
/// Utility.RunGenericMethod&lt;object&gt;(
/// typeof(Utility),
/// nameof(MergeLists),
/// enumerableType,
/// oldValue, newValue, equalityComparer)
/// </code>
/// </example>
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
/// <param name="type">The generic type to run the method with.</param>
/// <param name="args">The list of arguments of the method</param>
/// <typeparam name="T">
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
/// </typeparam>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The return of the method you wanted to run.</returns>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/>
public static T? RunGenericMethod<T>(
Type owner,
string methodName,
Type type,
params object[] args
)
{
return RunGenericMethod<T>(owner, methodName, new[] { type }, args);
}
/// <summary>
/// Run a generic static method for a multiple runtime <see cref="Type"/>.
/// If your generic method only needs one type, see
/// <see cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
/// </summary>
/// <example>
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
/// you could do:
/// <code>
/// Utility.RunGenericMethod&lt;object&gt;(
/// typeof(Utility),
/// nameof(MergeLists),
/// enumerableType,
/// oldValue, newValue, equalityComparer)
/// </code>
/// </example>
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
/// <param name="types">The list of generic types to run the method with.</param>
/// <param name="args">The list of arguments of the method</param>
/// <typeparam name="T">
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
/// </typeparam>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The return of the method you wanted to run.</returns>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
public static T? RunGenericMethod<T>(
Type owner,
string methodName,
Type[] types,
params object?[] args
)
{
if (types.Length < 1)
throw new ArgumentException(
$"The {nameof(types)} array is empty. At least one type is needed."
);
MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args);
return (T?)method.MakeGenericMethod(types).Invoke(null, args);
}
/// <summary>
/// Convert a dictionary to a query string.
/// </summary>

View File

@ -0,0 +1,85 @@
// 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 <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Core.Controllers;
public class MiscRepository(
DatabaseContext context,
DbConnection database,
IThumbnailsManager thumbnails
)
{
public static async Task DownloadMissingImages(IServiceProvider services)
{
await using AsyncServiceScope scope = services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<MiscRepository>().DownloadMissingImages();
}
private async Task<ICollection<Image>> _GetAllImages()
{
string GetSql(string type) =>
$"""
select poster from {type}
union all select thumbnail from {type}
union all select logo from {type}
""";
var queries = new string[]
{
"movies",
"collections",
"shows",
"seasons",
"episodes"
}.Select(x => GetSql(x));
string sql = string.Join(" union all ", queries);
IEnumerable<Image?> ret = await database.QueryAsync<Image?>(sql);
return ret.Where(x => x != null).ToArray() as Image[];
}
public async Task DownloadMissingImages()
{
ICollection<Image> images = await _GetAllImages();
IEnumerable<Task> tasks = images
.Where(x => !File.Exists(thumbnails.GetImagePath(x.Id, ImageQuality.Low)))
.Select(x => thumbnails.DownloadImage(x, x.Id.ToString()));
// Chunk tasks to prevent http timouts
foreach (IEnumerable<Task> batch in tasks.Chunk(30))
await Task.WhenAll(batch);
}
public async Task<ICollection<string>> GetRegisteredPaths()
{
return await context
.Episodes.Select(x => x.Path)
.Concat(context.Movies.Select(x => x.Path))
.ToListAsync();
}
}

View File

@ -31,46 +31,21 @@ namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle collections
/// </summary>
public class CollectionRepository : LocalRepository<Collection>
public class CollectionRepository(DatabaseContext database, IThumbnailsManager thumbnails)
: GenericRepository<Collection>(database)
{
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
/// <summary>
/// Create a new <see cref="CollectionRepository"/>.
/// </summary>
/// <param name="database">The database handle to use</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{
_database = database;
}
/// <inheritdoc />
public override async Task<ICollection<Collection>> Search(
string query,
Include<Collection>? include = default
)
{
return await AddIncludes(_database.Collections, include)
return await AddIncludes(Database.Collections, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task<Collection> Create(Collection obj)
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Collection>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Collection resource)
{
@ -78,25 +53,18 @@ public class CollectionRepository : LocalRepository<Collection>
if (string.IsNullOrEmpty(resource.Name))
throw new ArgumentException("The collection's name must be set and not empty");
await thumbnails.DownloadImages(resource);
}
public async Task AddMovie(Guid id, Guid movieId)
{
_database.AddLinks<Collection, Movie>(id, movieId);
await _database.SaveChangesAsync();
Database.AddLinks<Collection, Movie>(id, movieId);
await Database.SaveChangesAsync();
}
public async Task AddShow(Guid id, Guid showId)
{
_database.AddLinks<Collection, Show>(id, showId);
await _database.SaveChangesAsync();
}
/// <inheritdoc />
public override async Task Delete(Collection obj)
{
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
Database.AddLinks<Collection, Show>(id, showId);
await Database.SaveChangesAsync();
}
}

View File

@ -252,7 +252,7 @@ public static class DapperHelper
this IDbConnection db,
FormattableString command,
Dictionary<string, Type> config,
Func<List<object?>, T> mapper,
Func<IList<object?>, T> mapper,
Func<Guid, Task<T>> get,
SqlVariableContext context,
Include<T>? include,
@ -327,23 +327,6 @@ public static class DapperHelper
? ExpendProjections(typeV, prefix, include)
: null;
if (typeV.IsAssignableTo(typeof(IThumbnails)))
{
string posterProj = string.Join(
", ",
new[] { "poster", "thumbnail", "logo" }.Select(x =>
$"{prefix}{x}_source as source, {prefix}{x}_blurhash as blurhash"
)
);
projection = string.IsNullOrEmpty(projection)
? posterProj
: $"{projection}, {posterProj}";
types.InsertRange(
types.IndexOf(typeV) + 1,
Enumerable.Repeat(typeof(Image), 3)
);
}
if (string.IsNullOrEmpty(projection))
return leadingComa;
return $", {projection}{leadingComa}";
@ -355,19 +338,7 @@ public static class DapperHelper
types.ToArray(),
items =>
{
List<object?> nItems = new(items.Length);
for (int i = 0; i < items.Length; i++)
{
if (types[i] == typeof(Image))
continue;
nItems.Add(items[i]);
if (items[i] is not IThumbnails thumbs)
continue;
thumbs.Poster = items[++i] as Image;
thumbs.Thumbnail = items[++i] as Image;
thumbs.Logo = items[++i] as Image;
}
return mapIncludes(mapper(nItems), nItems.Skip(config.Count));
return mapIncludes(mapper(items), items.Skip(config.Count));
},
ParametersDictionary.LoadFrom(cmd),
splitOn: string.Join(
@ -384,7 +355,7 @@ public static class DapperHelper
this IDbConnection db,
FormattableString command,
Dictionary<string, Type> config,
Func<List<object?>, T> mapper,
Func<IList<object?>, T> mapper,
SqlVariableContext context,
Include<T>? include,
Filter<T>? filter,

View File

@ -37,7 +37,7 @@ public abstract class DapperRepository<T> : IRepository<T>
protected abstract Dictionary<string, Type> Config { get; }
protected abstract T Mapper(List<object?> items);
protected abstract T Mapper(IList<object?> items);
protected DbConnection Database { get; init; }

View File

@ -18,6 +18,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
@ -35,8 +36,8 @@ namespace Kyoo.Core.Controllers;
public class EpisodeRepository(
DatabaseContext database,
IRepository<Show> shows,
IThumbnailsManager thumbs
) : LocalRepository<Episode>(database, thumbs)
IThumbnailsManager thumbnails
) : GenericRepository<Episode>(database)
{
static EpisodeRepository()
{
@ -64,70 +65,77 @@ public class EpisodeRepository(
Include<Episode>? include = default
)
{
return await AddIncludes(database.Episodes, include)
return await AddIncludes(Database.Episodes, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20)
.ToListAsync();
}
protected override Task<Episode?> GetDuplicated(Episode item)
{
if (item is { SeasonNumber: not null, EpisodeNumber: not null })
return database.Episodes.FirstOrDefaultAsync(x =>
x.ShowId == item.ShowId
&& x.SeasonNumber == item.SeasonNumber
&& x.EpisodeNumber == item.EpisodeNumber
);
return database.Episodes.FirstOrDefaultAsync(x =>
x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber
);
}
/// <inheritdoc />
public override async Task<Episode> Create(Episode obj)
{
// Set it for the OnResourceCreated event and the return value.
obj.ShowSlug = obj.Show?.Slug ?? (await shows.Get(obj.ShowId)).Slug;
await base.Create(obj);
database.Entry(obj).State = EntityState.Added;
await database.SaveChangesAsync(() => GetDuplicated(obj));
await IRepository<Episode>.OnResourceCreated(obj);
return obj;
return await base.Create(obj);
}
/// <inheritdoc />
protected override async Task Validate(Episode resource)
{
await base.Validate(resource);
resource.Show = null;
if (resource.ShowId == Guid.Empty)
{
if (resource.Show == null)
{
throw new ArgumentException(
$"Can't store an episode not related "
+ $"to any show (showID: {resource.ShowId})."
);
}
resource.ShowId = resource.Show.Id;
}
throw new ValidationException("Missing show id");
resource.Season = null;
if (resource.SeasonId == null && resource.SeasonNumber != null)
{
resource.Season = await database.Seasons.FirstOrDefaultAsync(x =>
x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
);
resource.SeasonId = await Database
.Seasons.Where(x =>
x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
)
.Select(x => x.Id)
.FirstOrDefaultAsync();
}
await thumbnails.DownloadImages(resource);
}
/// <inheritdoc />
public override async Task Delete(Episode obj)
{
int epCount = await database
int epCount = await Database
.Episodes.Where(x => x.ShowId == obj.ShowId)
.Take(2)
.CountAsync();
database.Entry(obj).State = EntityState.Deleted;
await database.SaveChangesAsync();
await base.Delete(obj);
if (epCount == 1)
await shows.Delete(obj.ShowId);
else
await base.Delete(obj);
}
/// <inheritdoc/>
public override async Task DeleteAll(Filter<Episode> filter)
{
ICollection<Episode> items = await GetAll(filter);
Guid[] ids = items.Select(x => x.Id).ToArray();
await Database.Set<Episode>().Where(x => ids.Contains(x.Id)).ExecuteDeleteAsync();
foreach (Episode resource in items)
await IRepository<Episode>.OnResourceDeleted(resource);
Guid[] showIds = await Database
.Set<Episode>()
.Where(filter.ToEfLambda())
.Select(x => x.Show!)
.Where(x => !x.Episodes!.Any())
.Select(x => x.Id)
.ToArrayAsync();
if (!showIds.Any())
return;
Filter<Show>[] showFilters = showIds
.Select(x => new Filter<Show>.Eq(nameof(Show.Id), x))
.ToArray();
await shows.DeleteAll(Filter.Or(showFilters)!);
}
}

View File

@ -18,6 +18,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
@ -29,38 +30,14 @@ using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql;
using Kyoo.Utils;
using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers;
/// <summary>
/// A base class to create repositories using Entity Framework.
/// </summary>
/// <typeparam name="T">The type of this repository</typeparam>
public abstract class LocalRepository<T> : IRepository<T>
public abstract class GenericRepository<T>(DatabaseContext database) : IRepository<T>
where T : class, IResource, IQuery
{
/// <summary>
/// The Entity Framework's Database handle.
/// </summary>
protected DbContext Database { get; }
/// <summary>
/// The thumbnail manager used to store images.
/// </summary>
private readonly IThumbnailsManager _thumbs;
/// <summary>
/// Create a new base <see cref="LocalRepository{T}"/> with the given database handle.
/// </summary>
/// <param name="database">A database connection to load resources of type <typeparamref name="T"/></param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
protected LocalRepository(DbContext database, IThumbnailsManager thumbs)
{
Database = database;
_thumbs = thumbs;
}
public DatabaseContext Database => database;
/// <inheritdoc/>
public Type RepositoryType => typeof(T);
@ -127,12 +104,6 @@ public abstract class LocalRepository<T> : IRepository<T>
return query;
}
/// <summary>
/// Get a resource from it's ID and make the <see cref="Database"/> instance track it.
/// </summary>
/// <param name="id">The ID of the resource</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The tracked resource with the given ID</returns>
protected virtual async Task<T> GetWithTracking(Guid id)
{
T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == id);
@ -174,11 +145,6 @@ public abstract class LocalRepository<T> : IRepository<T>
return ret;
}
protected virtual Task<T?> GetDuplicated(T item)
{
return GetOrDefault(item.Slug);
}
/// <inheritdoc />
public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default)
{
@ -303,26 +269,9 @@ public abstract class LocalRepository<T> : IRepository<T>
public virtual async Task<T> Create(T obj)
{
await Validate(obj);
if (obj is IThumbnails thumbs)
{
try
{
await _thumbs.DownloadImages(thumbs);
}
catch (DuplicatedItemException e) when (e.Existing is null)
{
throw new DuplicatedItemException(await GetDuplicated(obj));
}
if (thumbs.Poster != null)
Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State =
EntityState.Added;
if (thumbs.Thumbnail != null)
Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State =
EntityState.Added;
if (thumbs.Logo != null)
Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State =
EntityState.Added;
}
Database.Add(obj);
await Database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<T>.OnResourceCreated(obj);
return obj;
}
@ -346,27 +295,11 @@ public abstract class LocalRepository<T> : IRepository<T>
/// <inheritdoc/>
public virtual async Task<T> Edit(T edited)
{
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
Database.ChangeTracker.LazyLoadingEnabled = false;
try
{
T old = await GetWithTracking(edited.Id);
Merger.Complete(
old,
edited,
x => x.GetCustomAttribute<LoadableRelationAttribute>() == null
);
await EditRelations(old, edited);
await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(old);
return old;
}
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
await Validate(edited);
Database.Update(edited);
await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(edited);
return edited;
}
/// <inheritdoc/>
@ -391,39 +324,9 @@ public abstract class LocalRepository<T> : IRepository<T>
}
}
/// <summary>
/// An overridable method to edit relation of a resource.
/// </summary>
/// <param name="resource">
/// The non edited resource
/// </param>
/// <param name="changed">
/// The new version of <paramref name="resource"/>.
/// This item will be saved on the database and replace <paramref name="resource"/>
/// </param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task EditRelations(T resource, T changed)
{
if (resource is IThumbnails thumbs && changed is IThumbnails chng)
{
Database.Entry(thumbs).Reference(x => x.Poster).IsModified =
thumbs.Poster != chng.Poster;
Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified =
thumbs.Thumbnail != chng.Thumbnail;
Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo;
}
return Validate(resource);
}
/// <summary>
/// A method called just before saving a new resource to the database.
/// It is also called on the default implementation of <see cref="EditRelations"/>
/// </summary>
/// <param name="resource">The resource that will be saved</param>
/// <exception cref="ArgumentException">
/// <exception cref="ValidationException">
/// You can throw this if the resource is illegal and should not be saved.
/// </exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task Validate(T resource)
{
if (
@ -432,26 +335,9 @@ public abstract class LocalRepository<T> : IRepository<T>
)
return Task.CompletedTask;
if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("Resource can't have null as a slug.");
if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random")
{
try
{
MethodInfo? setter = typeof(T).GetProperty(nameof(resource.Slug))!.GetSetMethod();
if (setter != null)
setter.Invoke(resource, new object[] { resource.Slug + '!' });
else
throw new ArgumentException(
"Resources slug can't be number only or the literal \"random\"."
);
}
catch
{
throw new ArgumentException(
"Resources slug can't be number only or the literal \"random\"."
);
}
}
throw new ValidationException("Resource can't have null as a slug.");
if (resource.Slug == "random")
throw new ValidationException("Resources slug can't be the literal \"random\".");
return Task.CompletedTask;
}
@ -470,18 +356,20 @@ public abstract class LocalRepository<T> : IRepository<T>
}
/// <inheritdoc/>
public virtual Task Delete(T obj)
public virtual async Task Delete(T obj)
{
IRepository<T>.OnResourceDeleted(obj);
if (obj is IThumbnails thumbs)
return _thumbs.DeleteImages(thumbs);
return Task.CompletedTask;
await Database.Set<T>().Where(x => x.Id == obj.Id).ExecuteDeleteAsync();
await IRepository<T>.OnResourceDeleted(obj);
}
/// <inheritdoc/>
public async Task DeleteAll(Filter<T> filter)
public virtual async Task DeleteAll(Filter<T> filter)
{
foreach (T resource in await GetAll(filter))
await Delete(resource);
ICollection<T> items = await GetAll(filter);
Guid[] ids = items.Select(x => x.Id).ToArray();
await Database.Set<T>().Where(x => ids.Contains(x.Id)).ExecuteDeleteAsync();
foreach (T resource in items)
await IRepository<T>.OnResourceDeleted(resource);
}
}

View File

@ -30,7 +30,8 @@ namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle library items.
/// </summary>
public class LibraryItemRepository : DapperRepository<ILibraryItem>
public class LibraryItemRepository(DbConnection database, SqlVariableContext context)
: DapperRepository<ILibraryItem>(database, context)
{
// language=PostgreSQL
protected override FormattableString Sql =>
@ -67,7 +68,7 @@ public class LibraryItemRepository : DapperRepository<ILibraryItem>
{ "c", typeof(Collection) }
};
protected override ILibraryItem Mapper(List<object?> items)
protected override ILibraryItem Mapper(IList<object?> items)
{
if (items[0] is Show show && show.Id != Guid.Empty)
return show;
@ -78,9 +79,6 @@ public class LibraryItemRepository : DapperRepository<ILibraryItem>
throw new InvalidDataException();
}
public LibraryItemRepository(DbConnection database, SqlVariableContext context)
: base(database, context) { }
public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
Guid collectionId,
Filter<ILibraryItem>? filter = default,

View File

@ -21,58 +21,48 @@ using System.Linq;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle shows
/// </summary>
public class MovieRepository : LocalRepository<Movie>
public class MovieRepository(
DatabaseContext database,
IRepository<Studio> studios,
IThumbnailsManager thumbnails
) : GenericRepository<Movie>(database)
{
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
/// <summary>
/// A studio repository to handle creation/validation of related studios.
/// </summary>
private readonly IRepository<Studio> _studios;
public MovieRepository(
DatabaseContext database,
IRepository<Studio> studios,
IThumbnailsManager thumbs
)
: base(database, thumbs)
{
_database = database;
_studios = studios;
}
/// <inheritdoc />
public override async Task<ICollection<Movie>> Search(
string query,
Include<Movie>? include = default
)
{
return await AddIncludes(_database.Movies, include)
return await AddIncludes(Database.Movies, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task<Movie> Create(Movie obj)
public override Task<Movie> Create(Movie obj)
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Movie>.OnResourceCreated(obj);
return obj;
try
{
return base.Create(obj);
}
catch (DuplicatedItemException ex)
when (ex.Existing is Movie existing
&& existing.Slug == obj.Slug
&& obj.AirDate is not null
&& existing.AirDate?.Year != obj.AirDate?.Year
)
{
obj.Slug = $"{obj.Slug}-{obj.AirDate!.Value.Year}";
return base.Create(obj);
}
}
/// <inheritdoc />
@ -81,28 +71,9 @@ public class MovieRepository : LocalRepository<Movie>
await base.Validate(resource);
if (resource.Studio != null)
{
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
resource.StudioId = resource.Studio.Id;
resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id;
resource.Studio = null;
}
}
/// <inheritdoc />
protected override async Task EditRelations(Movie resource, Movie changed)
{
await Validate(changed);
if (changed.Studio != null || changed.StudioId == null)
{
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
resource.Studio = changed.Studio;
}
}
/// <inheritdoc />
public override async Task Delete(Movie obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
await thumbnails.DownloadImages(resource);
}
}

View File

@ -49,7 +49,7 @@ public class NewsRepository : DapperRepository<INews>
protected override Dictionary<string, Type> Config =>
new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, };
protected override INews Mapper(List<object?> items)
protected override INews Mapper(IList<object?> items)
{
if (items[0] is Episode episode && episode.Id != Guid.Empty)
return episode;

View File

@ -31,16 +31,9 @@ using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle seasons.
/// </summary>
public class SeasonRepository : LocalRepository<Season>
public class SeasonRepository(DatabaseContext database, IThumbnailsManager thumbnails)
: GenericRepository<Season>(database)
{
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
static SeasonRepository()
{
// Edit seasons slugs when the show's slug changes.
@ -61,31 +54,13 @@ public class SeasonRepository : LocalRepository<Season>
};
}
/// <summary>
/// Create a new <see cref="SeasonRepository"/>.
/// </summary>
/// <param name="database">The database handle that will be used</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{
_database = database;
}
protected override Task<Season?> GetDuplicated(Season item)
{
return _database.Seasons.FirstOrDefaultAsync(x =>
x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber
);
}
/// <inheritdoc/>
public override async Task<ICollection<Season>> Search(
string query,
Include<Season>? include = default
)
{
return await AddIncludes(_database.Seasons, include)
return await AddIncludes(Database.Seasons, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20)
.ToListAsync();
@ -94,38 +69,20 @@ public class SeasonRepository : LocalRepository<Season>
/// <inheritdoc/>
public override async Task<Season> Create(Season obj)
{
await base.Create(obj);
// Set it for the OnResourceCreated event and the return value.
obj.ShowSlug =
(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
(await Database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => GetDuplicated(obj));
await IRepository<Season>.OnResourceCreated(obj);
return obj;
return await base.Create(obj);
}
/// <inheritdoc/>
protected override async Task Validate(Season resource)
{
await base.Validate(resource);
resource.Show = null;
if (resource.ShowId == Guid.Empty)
{
if (resource.Show == null)
{
throw new ValidationException(
$"Can't store a season not related to any show "
+ $"(showID: {resource.ShowId})."
);
}
resource.ShowId = resource.Show.Id;
}
}
/// <inheritdoc/>
public override async Task Delete(Season obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
throw new ValidationException("Missing show id");
await thumbnails.DownloadImages(resource);
}
}

View File

@ -21,59 +21,48 @@ using System.Linq;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql;
using Kyoo.Utils;
using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle shows
/// </summary>
public class ShowRepository : LocalRepository<Show>
public class ShowRepository(
DatabaseContext database,
IRepository<Studio> studios,
IThumbnailsManager thumbnails
) : GenericRepository<Show>(database)
{
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
/// <summary>
/// A studio repository to handle creation/validation of related studios.
/// </summary>
private readonly IRepository<Studio> _studios;
public ShowRepository(
DatabaseContext database,
IRepository<Studio> studios,
IThumbnailsManager thumbs
)
: base(database, thumbs)
{
_database = database;
_studios = studios;
}
/// <inheritdoc />
public override async Task<ICollection<Show>> Search(
string query,
Include<Show>? include = default
)
{
return await AddIncludes(_database.Shows, include)
return await AddIncludes(Database.Shows, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task<Show> Create(Show obj)
public override Task<Show> Create(Show obj)
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Show>.OnResourceCreated(obj);
return obj;
try
{
return base.Create(obj);
}
catch (DuplicatedItemException ex)
when (ex.Existing is Show existing
&& existing.Slug == obj.Slug
&& obj.StartAir is not null
&& existing.StartAir?.Year != obj.StartAir?.Year
)
{
obj.Slug = $"{obj.Slug}-{obj.AirDate!.Value.Year}";
return base.Create(obj);
}
}
/// <inheritdoc />
@ -82,28 +71,9 @@ public class ShowRepository : LocalRepository<Show>
await base.Validate(resource);
if (resource.Studio != null)
{
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
resource.StudioId = resource.Studio.Id;
resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id;
resource.Studio = null;
}
}
/// <inheritdoc />
protected override async Task EditRelations(Show resource, Show changed)
{
await Validate(changed);
if (changed.Studio != null || changed.StudioId == null)
{
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
resource.Studio = changed.Studio;
}
}
/// <inheritdoc />
public override async Task Delete(Show obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
await thumbnails.DownloadImages(resource);
}
}

View File

@ -19,11 +19,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql;
using Kyoo.Utils;
using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers;
@ -31,51 +29,17 @@ namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle studios
/// </summary>
public class StudioRepository : LocalRepository<Studio>
public class StudioRepository(DatabaseContext database) : GenericRepository<Studio>(database)
{
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
/// <summary>
/// Create a new <see cref="StudioRepository"/>.
/// </summary>
/// <param name="database">The database handle</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{
_database = database;
}
/// <inheritdoc />
public override async Task<ICollection<Studio>> Search(
string query,
Include<Studio>? include = default
)
{
return await AddIncludes(_database.Studios, include)
return await AddIncludes(Database.Studios, include)
.Where(x => EF.Functions.ILike(x.Name, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task<Studio> Create(Studio obj)
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Studio>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
public override async Task Delete(Studio obj)
{
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
}
}

View File

@ -40,9 +40,8 @@ public class UserRepository(
DatabaseContext database,
DbConnection db,
SqlVariableContext context,
IThumbnailsManager thumbs,
PermissionOption options
) : LocalRepository<User>(database, thumbs), IUserRepository
) : GenericRepository<User>(database), IUserRepository
{
/// <inheritdoc />
public override async Task<ICollection<User>> Search(
@ -50,7 +49,7 @@ public class UserRepository(
Include<User>? include = default
)
{
return await AddIncludes(database.Users, include)
return await AddIncludes(Database.Users, include)
.Where(x => EF.Functions.ILike(x.Username, $"%{query}%"))
.Take(20)
.ToListAsync();
@ -60,26 +59,14 @@ public class UserRepository(
public override async Task<User> Create(User obj)
{
// If no users exists, the new one will be an admin. Give it every permissions.
if (!await database.Users.AnyAsync())
if (!await Database.Users.AnyAsync())
obj.Permissions = PermissionOption.Admin;
else if (!options.RequireVerification)
obj.Permissions = options.NewUser;
else
obj.Permissions = Array.Empty<string>();
await base.Create(obj);
database.Entry(obj).State = EntityState.Added;
await database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<User>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
public override async Task Delete(User obj)
{
database.Entry(obj).State = EntityState.Deleted;
await database.SaveChangesAsync();
await base.Delete(obj);
return await base.Create(obj);
}
public Task<User?> GetByExternalId(string provider, string id)
@ -109,8 +96,8 @@ public class UserRepository(
User user = await GetWithTracking(userId);
user.ExternalId[provider] = token;
// without that, the change tracker does not find the modification. /shrug
database.Entry(user).Property(x => x.ExternalId).IsModified = true;
await database.SaveChangesAsync();
Database.Entry(user).Property(x => x.ExternalId).IsModified = true;
await Database.SaveChangesAsync();
return user;
}
@ -119,8 +106,8 @@ public class UserRepository(
User user = await GetWithTracking(userId);
user.ExternalId.Remove(provider);
// without that, the change tracker does not find the modification. /shrug
database.Entry(user).Property(x => x.ExternalId).IsModified = true;
await database.SaveChangesAsync();
Database.Entry(user).Property(x => x.ExternalId).IsModified = true;
await Database.SaveChangesAsync();
return user;
}
}

View File

@ -135,7 +135,7 @@ public class WatchStatusRepository(
{ "_mw", typeof(MovieWatchStatus) },
};
protected IWatchlist Mapper(List<object?> items)
protected IWatchlist Mapper(IList<object?> items)
{
if (items[0] is Show show && show.Id != Guid.Empty)
{

View File

@ -42,8 +42,6 @@ public class ThumbnailsManager(
Lazy<IRepository<User>> users
) : IThumbnailsManager
{
private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading = [];
private static async Task _WriteTo(SKBitmap bitmap, string path, int quality)
{
SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality);
@ -52,12 +50,15 @@ public class ThumbnailsManager(
await reader.CopyToAsync(file);
}
private async Task _DownloadImage(Image? image, string localPath, string what)
public async Task DownloadImage(Image? image, string what)
{
if (image == null)
return;
try
{
if (image.Id == Guid.Empty)
image.Id = Guid.NewGuid();
logger.LogInformation("Downloading image {What}", what);
HttpClient client = clientFactory.CreateClient();
@ -79,31 +80,19 @@ public class ThumbnailsManager(
new SKSizeI(original.Width, original.Height),
SKFilterQuality.High
);
await _WriteTo(
original,
$"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp",
90
);
await _WriteTo(original, GetImagePath(image.Id, ImageQuality.High), 90);
using SKBitmap medium = high.Resize(
new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)),
SKFilterQuality.Medium
);
await _WriteTo(
medium,
$"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp",
75
);
await _WriteTo(medium, GetImagePath(image.Id, ImageQuality.Medium), 75);
using SKBitmap low = medium.Resize(
new SKSizeI(original.Width / 2, original.Height / 2),
SKFilterQuality.Low
);
await _WriteTo(
low,
$"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp",
50
);
await _WriteTo(low, GetImagePath(image.Id, ImageQuality.Low), 50);
image.Blurhash = Blurhasher.Encode(low, 4, 3);
}
@ -119,86 +108,24 @@ public class ThumbnailsManager(
{
string name = item is IResource res ? res.Slug : "???";
string posterPath =
$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp";
bool duplicated = false;
TaskCompletionSource<object>? sync = null;
try
{
lock (_downloading)
{
if (_downloading.ContainsKey(posterPath))
{
duplicated = true;
sync = _downloading.GetValueOrDefault(posterPath);
}
else
{
sync = new();
_downloading.Add(posterPath, sync);
}
}
if (duplicated)
{
object? dup = sync != null ? await sync.Task : null;
if (dup != null)
throw new DuplicatedItemException(dup);
}
await _DownloadImage(
item.Poster,
_GetBaseImagePath(item, "poster"),
$"The poster of {name}"
);
await _DownloadImage(
item.Thumbnail,
_GetBaseImagePath(item, "thumbnail"),
$"The poster of {name}"
);
await _DownloadImage(
item.Logo,
_GetBaseImagePath(item, "logo"),
$"The poster of {name}"
);
}
finally
{
if (!duplicated)
{
lock (_downloading)
{
_downloading.Remove(posterPath);
sync!.SetResult(item);
}
}
}
}
private static string _GetBaseImagePath<T>(T item, string image)
{
string directory = item switch
{
IResource res
=> Path.Combine("/metadata", item.GetType().Name.ToLowerInvariant(), res.Slug),
_ => Path.Combine("/metadata", typeof(T).Name.ToLowerInvariant())
};
Directory.CreateDirectory(directory);
return Path.Combine(directory, image);
await DownloadImage(item.Poster, $"The poster of {name}");
await DownloadImage(item.Thumbnail, $"The thumbnail of {name}");
await DownloadImage(item.Logo, $"The logo of {name}");
}
/// <inheritdoc />
public string GetImagePath<T>(T item, string image, ImageQuality quality)
where T : IThumbnails
public string GetImagePath(Guid imageId, ImageQuality quality)
{
return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp";
return $"/metadata/{imageId}.{quality.ToString().ToLowerInvariant()}.webp";
}
/// <inheritdoc />
public Task DeleteImages<T>(T item)
where T : IThumbnails
{
IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" }
.SelectMany(x => _GetBaseImagePath(item, x))
IEnumerable<string> images = new[] { item.Poster?.Id, item.Thumbnail?.Id, item.Logo?.Id }
.Where(x => x is not null)
.SelectMany(x => $"/metadata/{x}")
.SelectMany(x =>
new[]
{

View File

@ -63,5 +63,6 @@ public static class CoreModule
);
builder.Services.AddScoped<IIssueRepository, IssueRepository>();
builder.Services.AddScoped<SqlVariableContext>();
builder.Services.AddScoped<MiscRepository>();
}
}

View File

@ -16,13 +16,12 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Serialization;
using AspNetCore.Proxy;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Authentication;
using Kyoo.Core.Api;
using Kyoo.Core.Controllers;
using Kyoo.Utils;
@ -47,6 +46,8 @@ public static class ServiceExtensions
options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider());
options.ModelBinderProviders.Insert(0, new FilterBinder.Provider());
})
.AddApplicationPart(typeof(CoreModule).Assembly)
.AddApplicationPart(typeof(AuthenticationModule).Assembly)
.AddJsonOptions(x =>
{
x.JsonSerializerOptions.TypeInfoResolver = new JsonKindResolver()

View File

@ -17,9 +17,9 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.IO;
using Kyoo.Authentication;
using Kyoo.Core;
using Kyoo.Core.Controllers;
using Kyoo.Core.Extensions;
using Kyoo.Meiliseach;
using Kyoo.Postgresql;
@ -70,11 +70,6 @@ AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception");
builder.Host.UseSerilog();
builder
.Services.AddMvcCore()
.AddApplicationPart(typeof(CoreModule).Assembly)
.AddApplicationPart(typeof(AuthenticationModule).Assembly);
builder.Services.ConfigureMvc();
builder.Services.ConfigureOpenApi();
builder.ConfigureKyoo();
@ -93,42 +88,6 @@ app.UseRouting();
app.UseAuthentication();
app.MapControllers();
// TODO: wait 4.5.0 and delete this
static void MoveAll(DirectoryInfo source, DirectoryInfo target)
{
if (source.FullName == target.FullName)
return;
Directory.CreateDirectory(target.FullName);
foreach (FileInfo fi in source.GetFiles())
fi.MoveTo(Path.Combine(target.ToString(), fi.Name), true);
foreach (DirectoryInfo diSourceSubDir in source.GetDirectories())
{
DirectoryInfo nextTargetSubDir = target.CreateSubdirectory(diSourceSubDir.Name);
MoveAll(diSourceSubDir, nextTargetSubDir);
}
Directory.Delete(source.FullName);
}
try
{
string oldDir = "/kyoo/kyoo_datadir/metadata";
if (Path.Exists(oldDir))
{
MoveAll(new DirectoryInfo(oldDir), new DirectoryInfo("/metadata"));
Log.Warning("Old metadata directory migrated.");
}
}
catch (Exception ex)
{
Log.Fatal(
ex,
"Unhandled error while trying to migrate old metadata images to new directory. Giving up and continuing normal startup."
);
}
// Activate services that always run in the background
app.Services.GetRequiredService<MeiliSync>();
app.Services.GetRequiredService<RabbitProducer>();
@ -138,4 +97,7 @@ await using (AsyncServiceScope scope = app.Services.CreateAsyncScope())
await MeilisearchModule.Initialize(scope.ServiceProvider);
}
app.Run(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000");
// The methods takes care of creating a scope and will download images on the background.
_ = MiscRepository.DownloadMissingImages(app.Services);
await app.RunAsync(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000");

View File

@ -30,19 +30,8 @@ namespace Kyoo.Core.Api;
[Route("health")]
[ApiController]
[ApiDefinition("Health")]
public class Health : BaseApi
public class Health(HealthCheckService healthCheckService) : BaseApi
{
private readonly HealthCheckService _healthCheckService;
/// <summary>
/// Create a new <see cref="Health"/>.
/// </summary>
/// <param name="healthCheckService">The service to check health.</param>
public Health(HealthCheckService healthCheckService)
{
_healthCheckService = healthCheckService;
}
/// <summary>
/// Check if the api is ready to accept requests.
/// </summary>
@ -57,7 +46,7 @@ public class Health : BaseApi
headers.Pragma = "no-cache";
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
HealthReport result = await _healthCheckService.CheckHealthAsync();
HealthReport result = await healthCheckService.CheckHealthAsync();
return result.Status switch
{
HealthStatus.Healthy => Ok(new HealthResult("Healthy")),

View File

@ -0,0 +1,45 @@
// 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 <https://www.gnu.org/licenses/>.
using System.Collections.Generic;
using System.Threading.Tasks;
using Kyoo.Abstractions.Models.Permissions;
using Kyoo.Core.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Core.Api;
/// <summary>
/// Private APIs only used for other services. Can change at any time without notice.
/// </summary>
[ApiController]
[Permission(nameof(Misc), Kind.Read, Group = Group.Admin)]
public class Misc(MiscRepository repo) : BaseApi
{
/// <summary>
/// List all registered paths.
/// </summary>
/// <returns>The list of paths known to Kyoo.</returns>
[HttpGet("/paths")]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<ICollection<string>> GetAllPaths()
{
return repo.GetRegisteredPaths();
}
}

View File

@ -0,0 +1,67 @@
// 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 <https://www.gnu.org/licenses/>.
using System;
using System.IO;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Permissions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api;
/// <summary>
/// Retrive images.
/// </summary>
[ApiController]
[Route("thumbnails")]
[Route("images", Order = AlternativeRoute)]
[Route("image", Order = AlternativeRoute)]
[Permission(nameof(Image), Kind.Read, Group = Group.Overall)]
[ApiDefinition("Images", Group = OtherGroup)]
public class ThumbnailsApi(IThumbnailsManager thumbs) : BaseApi
{
/// <summary>
/// Get Image
/// </summary>
/// <remarks>
/// Get an image from it's id. You can select a specefic quality.
/// </remarks>
/// <param name="id">The ID of the image to retrive.</param>
/// <param name="quality">The quality of the image to retrieve.</param>
/// <returns>The image asked.</returns>
/// <response code="404">
/// The image does not exists on kyoo.
/// </response>
[HttpGet("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetPoster(Guid id, [FromQuery] ImageQuality? quality)
{
string path = thumbs.GetImagePath(id, quality ?? ImageQuality.High);
if (!System.IO.File.Exists(path))
return NotFound();
// Allow clients to cache the image for 6 month.
Response.Headers.CacheControl = $"public, max-age={60 * 60 * 24 * 31 * 6}";
return PhysicalFile(Path.GetFullPath(path), "image/webp", true);
}
}

View File

@ -170,6 +170,33 @@ public class CrudApi<T> : BaseApi
return await Repository.Edit(resource);
}
/// <summary>
/// Edit
/// </summary>
/// <remarks>
/// Edit an item. If the ID is specified it will be used to identify the resource.
/// If not, the slug will be used to identify it.
/// </remarks>
/// <param name="identifier">The id or slug of the resource.</param>
/// <param name="resource">The resource to edit.</param>
/// <returns>The edited resource.</returns>
/// <response code="400">The resource in the request body is invalid.</response>
/// <response code="404">No item found with the specified ID (or slug).</response>
[HttpPut("{identifier:id}")]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> Edit(Identifier identifier, [FromBody] T resource)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await Repository.Get(slug)).Id
);
resource.Id = id;
return await Repository.Edit(resource);
}
/// <summary>
/// Patch
/// </summary>

View File

@ -16,7 +16,7 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.IO;
using System;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
@ -28,14 +28,8 @@ using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api;
/// <summary>
/// A base class to handle CRUD operations and services thumbnails for
/// a specific resource type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of resource to make CRUD and thumbnails apis for.</typeparam>
[ApiController]
public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thumbs)
: CrudApi<T>(repository)
public class CrudThumbsApi<T>(IRepository<T> repository) : CrudApi<T>(repository)
where T : class, IResource, IThumbnails, IQuery
{
private async Task<IActionResult> _GetImage(
@ -50,18 +44,19 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
);
if (resource == null)
return NotFound();
string path = thumbs.GetImagePath(resource, image, quality ?? ImageQuality.High);
if (!System.IO.File.Exists(path))
Image? img = image switch
{
"poster" => resource.Poster,
"thumbnail" => resource.Thumbnail,
"logo" => resource.Logo,
_ => throw new ArgumentException(nameof(image)),
};
if (img is null)
return NotFound();
if (!identifier.Match(id => false, slug => slug == "random"))
{
// Allow clients to cache the image for 6 month.
Response.Headers.CacheControl = $"public, max-age={60 * 60 * 24 * 31 * 6}";
}
else
Response.Headers.CacheControl = $"public, no-store";
return PhysicalFile(Path.GetFullPath(path), "image/webp", true);
// TODO: Remove the /api and use a proxy rewrite instead.
return Redirect($"/api/thumbnails/{img.Id}");
}
/// <summary>
@ -78,7 +73,7 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
/// </response>
[HttpGet("{identifier:id}/poster")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality)
{
@ -99,7 +94,7 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
/// </response>
[HttpGet("{identifier:id}/logo")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality)
{
@ -120,6 +115,8 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
/// </response>
[HttpGet("{identifier:id}/thumbnail")]
[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality)
{
return _GetImage(identifier, "thumbnail", quality);

View File

@ -35,8 +35,7 @@ public static class Transcoder
Environment.GetEnvironmentVariable("TRANSCODER_URL") ?? "http://transcoder:7666";
}
public abstract class TranscoderApi<T>(IRepository<T> repository, IThumbnailsManager thumbs)
: CrudThumbsApi<T>(repository, thumbs)
public abstract class TranscoderApi<T>(IRepository<T> repository) : CrudThumbsApi<T>(repository)
where T : class, IResource, IThumbnails, IQuery
{
private Task _Proxy(string route, (string path, string route) info)

View File

@ -40,25 +40,13 @@ namespace Kyoo.Core.Api;
[ApiController]
[PartialPermission(nameof(Collection))]
[ApiDefinition("Collections", Group = ResourcesGroup)]
public class CollectionApi : CrudThumbsApi<Collection>
public class CollectionApi(
IRepository<Movie> movies,
IRepository<Show> shows,
CollectionRepository collections,
LibraryItemRepository items
) : CrudThumbsApi<Collection>(collections)
{
private readonly ILibraryManager _libraryManager;
private readonly CollectionRepository _collections;
private readonly LibraryItemRepository _items;
public CollectionApi(
ILibraryManager libraryManager,
CollectionRepository collections,
LibraryItemRepository items,
IThumbnailsManager thumbs
)
: base(libraryManager.Collections, thumbs)
{
_libraryManager = libraryManager;
_collections = collections;
_items = items;
}
/// <summary>
/// Add a movie
/// </summary>
@ -79,14 +67,14 @@ public class CollectionApi : CrudThumbsApi<Collection>
public async Task<ActionResult> AddMovie(Identifier identifier, Identifier movie)
{
Guid collectionId = await identifier.Match(
async id => (await _libraryManager.Collections.Get(id)).Id,
async slug => (await _libraryManager.Collections.Get(slug)).Id
async id => (await collections.Get(id)).Id,
async slug => (await collections.Get(slug)).Id
);
Guid movieId = await movie.Match(
async id => (await _libraryManager.Movies.Get(id)).Id,
async slug => (await _libraryManager.Movies.Get(slug)).Id
async id => (await movies.Get(id)).Id,
async slug => (await movies.Get(slug)).Id
);
await _collections.AddMovie(collectionId, movieId);
await collections.AddMovie(collectionId, movieId);
return NoContent();
}
@ -110,14 +98,14 @@ public class CollectionApi : CrudThumbsApi<Collection>
public async Task<ActionResult> AddShow(Identifier identifier, Identifier show)
{
Guid collectionId = await identifier.Match(
async id => (await _libraryManager.Collections.Get(id)).Id,
async slug => (await _libraryManager.Collections.Get(slug)).Id
async id => (await collections.Get(id)).Id,
async slug => (await collections.Get(slug)).Id
);
Guid showId = await show.Match(
async id => (await _libraryManager.Shows.Get(id)).Id,
async slug => (await _libraryManager.Shows.Get(slug)).Id
async id => (await shows.Get(id)).Id,
async slug => (await shows.Get(slug)).Id
);
await _collections.AddShow(collectionId, showId);
await collections.AddShow(collectionId, showId);
return NoContent();
}
@ -151,9 +139,9 @@ public class CollectionApi : CrudThumbsApi<Collection>
{
Guid collectionId = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Collections.Get(slug)).Id
async slug => (await collections.Get(slug)).Id
);
ICollection<ILibraryItem> resources = await _items.GetAllOfCollection(
ICollection<ILibraryItem> resources = await items.GetAllOfCollection(
collectionId,
filter,
sortBy == new Sort<ILibraryItem>.Default()
@ -165,8 +153,7 @@ public class CollectionApi : CrudThumbsApi<Collection>
if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
&& await collections.GetOrDefault(identifier.IsSame<Collection>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);
@ -200,7 +187,7 @@ public class CollectionApi : CrudThumbsApi<Collection>
[FromQuery] Include<Show>? fields
)
{
ICollection<Show> resources = await _libraryManager.Shows.GetAll(
ICollection<Show> resources = await shows.GetAll(
Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)),
sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy,
fields,
@ -209,8 +196,7 @@ public class CollectionApi : CrudThumbsApi<Collection>
if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
&& await collections.GetOrDefault(identifier.IsSame<Collection>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);
@ -244,7 +230,7 @@ public class CollectionApi : CrudThumbsApi<Collection>
[FromQuery] Include<Movie>? fields
)
{
ICollection<Movie> resources = await _libraryManager.Movies.GetAll(
ICollection<Movie> resources = await movies.GetAll(
Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)),
sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy,
fields,
@ -253,8 +239,7 @@ public class CollectionApi : CrudThumbsApi<Collection>
if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
&& await collections.GetOrDefault(identifier.IsSame<Collection>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);

View File

@ -38,8 +38,8 @@ namespace Kyoo.Core.Api;
[ApiController]
[PartialPermission(nameof(Episode))]
[ApiDefinition("Episodes", Group = ResourcesGroup)]
public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails)
: TranscoderApi<Episode>(libraryManager.Episodes, thumbnails)
public class EpisodeApi(ILibraryManager libraryManager)
: TranscoderApi<Episode>(libraryManager.Episodes)
{
/// <summary>
/// Get episode's show

View File

@ -34,23 +34,5 @@ namespace Kyoo.Core.Api;
[ApiController]
[PartialPermission("LibraryItem")]
[ApiDefinition("Items", Group = ResourcesGroup)]
public class LibraryItemApi : CrudThumbsApi<ILibraryItem>
{
/// <summary>
/// The library item repository used to modify or retrieve information in the data store.
/// </summary>
private readonly IRepository<ILibraryItem> _libraryItems;
/// <summary>
/// Create a new <see cref="LibraryItemApi"/>.
/// </summary>
/// <param name="libraryItems">
/// The library item repository used to modify or retrieve information in the data store.
/// </param>
/// <param name="thumbs">Thumbnail manager to retrieve images.</param>
public LibraryItemApi(IRepository<ILibraryItem> libraryItems, IThumbnailsManager thumbs)
: base(libraryItems, thumbs)
{
_libraryItems = libraryItems;
}
}
public class LibraryItemApi(IRepository<ILibraryItem> libraryItems)
: CrudThumbsApi<ILibraryItem>(libraryItems) { }

View File

@ -40,8 +40,7 @@ namespace Kyoo.Core.Api;
[ApiController]
[PartialPermission(nameof(Show))]
[ApiDefinition("Shows", Group = ResourcesGroup)]
public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
: TranscoderApi<Movie>(libraryManager.Movies, thumbs)
public class MovieApi(ILibraryManager libraryManager) : TranscoderApi<Movie>(libraryManager.Movies)
{
/// <summary>
/// Get studio that made the show

View File

@ -33,8 +33,4 @@ namespace Kyoo.Core.Api;
[ApiController]
[PartialPermission("LibraryItem")]
[ApiDefinition("News", Group = ResourcesGroup)]
public class NewsApi : CrudThumbsApi<INews>
{
public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs)
: base(news, thumbs) { }
}
public class NewsApi(IRepository<INews> news) : CrudThumbsApi<INews>(news) { }

View File

@ -38,26 +38,9 @@ namespace Kyoo.Core.Api;
[ApiController]
[PartialPermission(nameof(Season))]
[ApiDefinition("Seasons", Group = ResourcesGroup)]
public class SeasonApi : CrudThumbsApi<Season>
public class SeasonApi(ILibraryManager libraryManager)
: CrudThumbsApi<Season>(libraryManager.Seasons)
{
/// <summary>
/// The library manager used to modify or retrieve information in the data store.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Create a new <see cref="SeasonApi"/>.
/// </summary>
/// <param name="libraryManager">
/// The library manager used to modify or retrieve information in the data store.
/// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
: base(libraryManager.Seasons, thumbs)
{
_libraryManager = libraryManager;
}
/// <summary>
/// Get episodes in the season
/// </summary>
@ -86,7 +69,7 @@ public class SeasonApi : CrudThumbsApi<Season>
[FromQuery] Include<Episode> fields
)
{
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
ICollection<Episode> resources = await libraryManager.Episodes.GetAll(
Filter.And(filter, identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)),
sortBy,
fields,
@ -95,7 +78,7 @@ public class SeasonApi : CrudThumbsApi<Season>
if (
!resources.Any()
&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null
&& await libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);
@ -120,7 +103,7 @@ public class SeasonApi : CrudThumbsApi<Season>
[FromQuery] Include<Show> fields
)
{
Show? ret = await _libraryManager.Shows.GetOrDefault(
Show? ret = await libraryManager.Shows.GetOrDefault(
identifier.IsContainedIn<Show, Season>(x => x.Seasons!),
fields
);

View File

@ -40,26 +40,8 @@ namespace Kyoo.Core.Api;
[ApiController]
[PartialPermission(nameof(Show))]
[ApiDefinition("Shows", Group = ResourcesGroup)]
public class ShowApi : CrudThumbsApi<Show>
public class ShowApi(ILibraryManager libraryManager) : CrudThumbsApi<Show>(libraryManager.Shows)
{
/// <summary>
/// The library manager used to modify or retrieve information in the data store.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Create a new <see cref="ShowApi"/>.
/// </summary>
/// <param name="libraryManager">
/// The library manager used to modify or retrieve information about the data store.
/// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
: base(libraryManager.Shows, thumbs)
{
_libraryManager = libraryManager;
}
/// <summary>
/// Get seasons of this show
/// </summary>
@ -88,7 +70,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Season> fields
)
{
ICollection<Season> resources = await _libraryManager.Seasons.GetAll(
ICollection<Season> resources = await libraryManager.Seasons.GetAll(
Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)),
sortBy,
fields,
@ -97,7 +79,7 @@ public class ShowApi : CrudThumbsApi<Show>
if (
!resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
&& await libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);
@ -131,7 +113,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Episode> fields
)
{
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
ICollection<Episode> resources = await libraryManager.Episodes.GetAll(
Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)),
sortBy,
fields,
@ -140,7 +122,7 @@ public class ShowApi : CrudThumbsApi<Show>
if (
!resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
&& await libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);
@ -165,7 +147,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Studio> fields
)
{
return await _libraryManager.Studios.Get(
return await libraryManager.Studios.Get(
identifier.IsContainedIn<Studio, Show>(x => x.Shows!),
fields
);
@ -199,7 +181,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Collection> fields
)
{
ICollection<Collection> resources = await _libraryManager.Collections.GetAll(
ICollection<Collection> resources = await libraryManager.Collections.GetAll(
Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)),
sortBy,
fields,
@ -208,7 +190,7 @@ public class ShowApi : CrudThumbsApi<Show>
if (
!resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
&& await libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);
@ -233,9 +215,9 @@ public class ShowApi : CrudThumbsApi<Show>
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Shows.Get(slug)).Id
async slug => (await libraryManager.Shows.Get(slug)).Id
);
return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow());
return await libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow());
}
/// <summary>
@ -260,9 +242,9 @@ public class ShowApi : CrudThumbsApi<Show>
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Shows.Get(slug)).Id
async slug => (await libraryManager.Shows.Get(slug)).Id
);
return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status);
return await libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status);
}
/// <summary>
@ -283,8 +265,8 @@ public class ShowApi : CrudThumbsApi<Show>
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Shows.Get(slug)).Id
async slug => (await libraryManager.Shows.Get(slug)).Id
);
await _libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow());
await libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow());
}
}

View File

@ -16,9 +16,6 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Meilisearch;

View File

@ -23,7 +23,6 @@ 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;
@ -33,13 +32,6 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace Kyoo.Postgresql;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// It should not be used directly, to access the database use a <see cref="ILibraryManager"/> or repositories.
/// </remarks>
public abstract class DatabaseContext : DbContext
{
private readonly IHttpContextAccessor _accessor;
@ -53,39 +45,18 @@ public abstract class DatabaseContext : DbContext
public Guid? CurrentUserId => _accessor.HttpContext?.User.GetId();
/// <summary>
/// All collections of Kyoo. See <see cref="Collection"/>.
/// </summary>
public DbSet<Collection> Collections { get; set; }
/// <summary>
/// All movies of Kyoo. See <see cref="Movie"/>.
/// </summary>
public DbSet<Movie> Movies { get; set; }
/// <summary>
/// All shows of Kyoo. See <see cref="Show"/>.
/// </summary>
public DbSet<Show> Shows { get; set; }
/// <summary>
/// All seasons of Kyoo. See <see cref="Season"/>.
/// </summary>
public DbSet<Season> Seasons { get; set; }
/// <summary>
/// All episodes of Kyoo. See <see cref="Episode"/>.
/// </summary>
public DbSet<Episode> Episodes { get; set; }
/// <summary>
/// All studios of Kyoo. See <see cref="Studio"/>.
/// </summary>
public DbSet<Studio> Studios { get; set; }
/// <summary>
/// The list of registered users.
/// </summary>
public DbSet<User> Users { get; set; }
public DbSet<MovieWatchStatus> MovieWatchStatus { get; set; }
@ -129,28 +100,13 @@ public abstract class DatabaseContext : DbContext
_accessor = accessor;
}
/// <summary>
/// Get the name of the link table of the two given types.
/// </summary>
/// <typeparam name="T">The owner type of the relation</typeparam>
/// <typeparam name="T2">The child type of the relation</typeparam>
/// <returns>The name of the table containing the links.</returns>
protected abstract string LinkName<T, T2>()
where T : IResource
where T2 : IResource;
/// <summary>
/// Get the name of a link's foreign key.
/// </summary>
/// <typeparam name="T">The type that will be accessible via the navigation</typeparam>
/// <returns>The name of the foreign key for the given resource.</returns>
protected abstract string LinkNameFk<T>()
where T : IResource;
/// <summary>
/// Set basic configurations (like preventing query tracking)
/// </summary>
/// <param name="optionsBuilder">An option builder to fill.</param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
@ -201,9 +157,9 @@ public abstract class DatabaseContext : DbContext
private static void _HasImages<T>(ModelBuilder modelBuilder)
where T : class, IThumbnails
{
modelBuilder.Entity<T>().OwnsOne(x => x.Poster);
modelBuilder.Entity<T>().OwnsOne(x => x.Thumbnail);
modelBuilder.Entity<T>().OwnsOne(x => x.Logo);
modelBuilder.Entity<T>().OwnsOne(x => x.Poster, x => x.ToJson());
modelBuilder.Entity<T>().OwnsOne(x => x.Thumbnail, x => x.ToJson());
modelBuilder.Entity<T>().OwnsOne(x => x.Logo, x => x.ToJson());
}
private static void _HasAddedDate<T>(ModelBuilder modelBuilder)
@ -227,15 +183,6 @@ public abstract class DatabaseContext : DbContext
.ValueGeneratedOnAdd();
}
/// <summary>
/// Create a many to many relationship between the two entities.
/// The resulting relationship will have an available <see cref="AddLinks{T1,T2}"/> method.
/// </summary>
/// <param name="modelBuilder">The database model builder</param>
/// <param name="firstNavigation">The first navigation expression from T to T2</param>
/// <param name="secondNavigation">The second navigation expression from T2 to T</param>
/// <typeparam name="T">The owning type of the relationship</typeparam>
/// <typeparam name="T2">The owned type of the relationship</typeparam>
private void _HasManyToMany<T, T2>(
ModelBuilder modelBuilder,
Expression<Func<T, IEnumerable<T2>?>> firstNavigation,
@ -263,10 +210,6 @@ public abstract class DatabaseContext : DbContext
);
}
/// <summary>
/// Set database parameters to support every types of Kyoo.
/// </summary>
/// <param name="modelBuilder">The database's model builder.</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@ -412,28 +355,6 @@ public abstract class DatabaseContext : DbContext
_HasJson<Issue, object>(modelBuilder, x => x.Extra);
}
/// <summary>
/// Return a new or an in cache temporary object wih the same ID as the one given
/// </summary>
/// <param name="model">If a resource with the same ID is found in the database, it will be used.
/// <paramref name="model"/> will be used otherwise</param>
/// <typeparam name="T">The type of the resource</typeparam>
/// <returns>A resource that is now tracked by this context.</returns>
public T GetTemporaryObject<T>(T model)
where T : class, IResource
{
T? tmp = Set<T>().Local.FirstOrDefault(x => x.Id == model.Id);
if (tmp != null)
return tmp;
Entry(model).State = EntityState.Unchanged;
return model;
}
/// <summary>
/// Save changes that are applied to this context.
/// </summary>
/// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception>
/// <returns>The number of state entries written to the database.</returns>
public override int SaveChanges()
{
try
@ -449,13 +370,6 @@ public abstract class DatabaseContext : DbContext
}
}
/// <summary>
/// Save changes that are applied to this context.
/// </summary>
/// <param name="acceptAllChangesOnSuccess">Indicates whether AcceptAllChanges() is called after the changes
/// have been sent successfully to the database.</param>
/// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception>
/// <returns>The number of state entries written to the database.</returns>
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
try
@ -471,14 +385,6 @@ public abstract class DatabaseContext : DbContext
}
}
/// <summary>
/// Save changes that are applied to this context.
/// </summary>
/// <param name="acceptAllChangesOnSuccess">Indicates whether AcceptAllChanges() is called after the changes
/// have been sent successfully to the database.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception>
/// <returns>The number of state entries written to the database.</returns>
public override async Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default
@ -497,12 +403,6 @@ public abstract class DatabaseContext : DbContext
}
}
/// <summary>
/// Save changes that are applied to this context.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception>
/// <returns>The number of state entries written to the database.</returns>
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
try
@ -518,14 +418,6 @@ public abstract class DatabaseContext : DbContext
}
}
/// <summary>
/// Save changes that are applied to this context.
/// </summary>
/// <param name="getExisting">How to retrieve the conflicting item.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception>
/// <typeparam name="T">The type of the potential duplicate (this is unused).</typeparam>
/// <returns>The number of state entries written to the database.</returns>
public async Task<int> SaveChangesAsync<T>(
Func<Task<T>> getExisting,
CancellationToken cancellationToken = default
@ -548,12 +440,6 @@ public abstract class DatabaseContext : DbContext
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <returns>The number of state entries written to the database or -1 if a duplicate exist.</returns>
public async Task<int> SaveIfNoDuplicates(CancellationToken cancellationToken = default)
{
try
@ -566,30 +452,14 @@ public abstract class DatabaseContext : DbContext
}
}
/// <summary>
/// Return the first resource with the given slug that is currently tracked by this context.
/// This allow one to limit redundant calls to <see cref="IRepository{T}.CreateIfNotExists"/> during the
/// same transaction and prevent fails from EF when two same entities are being tracked.
/// </summary>
/// <param name="slug">The slug of the resource to check</param>
/// <typeparam name="T">The type of entity to check</typeparam>
/// <returns>The local entity representing the resource with the given slug if it exists or null.</returns>
public T? LocalEntity<T>(string slug)
where T : class, IResource
{
return ChangeTracker.Entries<T>().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity;
}
/// <summary>
/// Check if the exception is a duplicated exception.
/// </summary>
/// <param name="ex">The exception to check</param>
/// <returns>True if the exception is a duplicate exception. False otherwise</returns>
protected abstract bool IsDuplicateException(Exception ex);
/// <summary>
/// Delete every changes that are on this context.
/// </summary>
public void DiscardChanges()
{
foreach (

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,464 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Kyoo.Postgresql.Migrations
{
/// <inheritdoc />
public partial class ReworkImages : Migration
{
private void MigrateImage(MigrationBuilder migrationBuilder, string table, string type)
{
migrationBuilder.Sql(
$"""
update {table} as r set {type} = json_build_object(
'Id', gen_random_uuid(),
'Source', r.{type}_source,
'Blurhash', r.{type}_blurhash
)
where r.{type}_source is not null
"""
);
}
private void UnMigrateImage(MigrationBuilder migrationBuilder, string table, string type)
{
migrationBuilder.Sql(
$"""
update {table} as r
set {type}_source = r.{type}->>'Source',
{type}_blurhash = r.{type}->>'Blurhash'
"""
);
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "logo",
table: "shows",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster",
table: "shows",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail",
table: "shows",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo",
table: "seasons",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster",
table: "seasons",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail",
table: "seasons",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo",
table: "movies",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster",
table: "movies",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail",
table: "movies",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo",
table: "episodes",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster",
table: "episodes",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail",
table: "episodes",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo",
table: "collections",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster",
table: "collections",
type: "jsonb",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail",
table: "collections",
type: "jsonb",
nullable: true
);
MigrateImage(migrationBuilder, "shows", "logo");
MigrateImage(migrationBuilder, "shows", "poster");
MigrateImage(migrationBuilder, "shows", "thumbnail");
MigrateImage(migrationBuilder, "seasons", "logo");
MigrateImage(migrationBuilder, "seasons", "poster");
MigrateImage(migrationBuilder, "seasons", "thumbnail");
MigrateImage(migrationBuilder, "movies", "logo");
MigrateImage(migrationBuilder, "movies", "poster");
MigrateImage(migrationBuilder, "movies", "thumbnail");
MigrateImage(migrationBuilder, "episodes", "logo");
MigrateImage(migrationBuilder, "episodes", "poster");
MigrateImage(migrationBuilder, "episodes", "thumbnail");
MigrateImage(migrationBuilder, "collections", "logo");
MigrateImage(migrationBuilder, "collections", "poster");
MigrateImage(migrationBuilder, "collections", "thumbnail");
migrationBuilder.DropColumn(name: "logo_blurhash", table: "shows");
migrationBuilder.DropColumn(name: "logo_source", table: "shows");
migrationBuilder.DropColumn(name: "poster_blurhash", table: "shows");
migrationBuilder.DropColumn(name: "poster_source", table: "shows");
migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "shows");
migrationBuilder.DropColumn(name: "thumbnail_source", table: "shows");
migrationBuilder.DropColumn(name: "logo_blurhash", table: "seasons");
migrationBuilder.DropColumn(name: "logo_source", table: "seasons");
migrationBuilder.DropColumn(name: "poster_blurhash", table: "seasons");
migrationBuilder.DropColumn(name: "poster_source", table: "seasons");
migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "seasons");
migrationBuilder.DropColumn(name: "thumbnail_source", table: "seasons");
migrationBuilder.DropColumn(name: "logo_blurhash", table: "movies");
migrationBuilder.DropColumn(name: "logo_source", table: "movies");
migrationBuilder.DropColumn(name: "poster_blurhash", table: "movies");
migrationBuilder.DropColumn(name: "poster_source", table: "movies");
migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "movies");
migrationBuilder.DropColumn(name: "thumbnail_source", table: "movies");
migrationBuilder.DropColumn(name: "logo_blurhash", table: "episodes");
migrationBuilder.DropColumn(name: "logo_source", table: "episodes");
migrationBuilder.DropColumn(name: "poster_blurhash", table: "episodes");
migrationBuilder.DropColumn(name: "poster_source", table: "episodes");
migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "episodes");
migrationBuilder.DropColumn(name: "thumbnail_source", table: "episodes");
migrationBuilder.DropColumn(name: "logo_blurhash", table: "collections");
migrationBuilder.DropColumn(name: "logo_source", table: "collections");
migrationBuilder.DropColumn(name: "poster_blurhash", table: "collections");
migrationBuilder.DropColumn(name: "poster_source", table: "collections");
migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "collections");
migrationBuilder.DropColumn(name: "thumbnail_source", table: "collections");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "logo_blurhash",
table: "shows",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_source",
table: "shows",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_blurhash",
table: "shows",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_source",
table: "shows",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_blurhash",
table: "shows",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_source",
table: "shows",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_blurhash",
table: "seasons",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_source",
table: "seasons",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_blurhash",
table: "seasons",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_source",
table: "seasons",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_blurhash",
table: "seasons",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_source",
table: "seasons",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_blurhash",
table: "movies",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_source",
table: "movies",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_blurhash",
table: "movies",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_source",
table: "movies",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_blurhash",
table: "movies",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_source",
table: "movies",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_blurhash",
table: "episodes",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_source",
table: "episodes",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_blurhash",
table: "episodes",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_source",
table: "episodes",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_blurhash",
table: "episodes",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_source",
table: "episodes",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_blurhash",
table: "collections",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "logo_source",
table: "collections",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_blurhash",
table: "collections",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "poster_source",
table: "collections",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_blurhash",
table: "collections",
type: "character varying(32)",
maxLength: 32,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "thumbnail_source",
table: "collections",
type: "text",
nullable: true
);
UnMigrateImage(migrationBuilder, "shows", "logo");
UnMigrateImage(migrationBuilder, "shows", "poster");
UnMigrateImage(migrationBuilder, "shows", "thumbnail");
UnMigrateImage(migrationBuilder, "seasons", "logo");
UnMigrateImage(migrationBuilder, "seasons", "poster");
UnMigrateImage(migrationBuilder, "seasons", "thumbnail");
UnMigrateImage(migrationBuilder, "movies", "logo");
UnMigrateImage(migrationBuilder, "movies", "poster");
UnMigrateImage(migrationBuilder, "movies", "thumbnail");
UnMigrateImage(migrationBuilder, "episodes", "logo");
UnMigrateImage(migrationBuilder, "episodes", "poster");
UnMigrateImage(migrationBuilder, "episodes", "thumbnail");
UnMigrateImage(migrationBuilder, "collections", "logo");
UnMigrateImage(migrationBuilder, "collections", "poster");
UnMigrateImage(migrationBuilder, "collections", "thumbnail");
migrationBuilder.DropColumn(name: "logo", table: "shows");
migrationBuilder.DropColumn(name: "poster", table: "shows");
migrationBuilder.DropColumn(name: "thumbnail", table: "shows");
migrationBuilder.DropColumn(name: "logo", table: "seasons");
migrationBuilder.DropColumn(name: "poster", table: "seasons");
migrationBuilder.DropColumn(name: "thumbnail", table: "seasons");
migrationBuilder.DropColumn(name: "logo", table: "movies");
migrationBuilder.DropColumn(name: "poster", table: "movies");
migrationBuilder.DropColumn(name: "thumbnail", table: "movies");
migrationBuilder.DropColumn(name: "logo", table: "episodes");
migrationBuilder.DropColumn(name: "poster", table: "episodes");
migrationBuilder.DropColumn(name: "thumbnail", table: "episodes");
migrationBuilder.DropColumn(name: "logo", table: "collections");
migrationBuilder.DropColumn(name: "poster", table: "collections");
migrationBuilder.DropColumn(name: "thumbnail", table: "collections");
}
}
}

View File

@ -731,24 +731,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{
b1.Property<Guid>("CollectionId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("logo_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("logo_source");
.HasColumnType("text");
b1.HasKey("CollectionId");
b1.ToTable("collections");
b1.ToJson("logo");
b1.WithOwner()
.HasForeignKey("CollectionId")
.HasConstraintName("fk_collections_collections_id");
@ -757,50 +759,55 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{
b1.Property<Guid>("CollectionId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("poster_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("poster_source");
.HasColumnType("text");
b1.HasKey("CollectionId");
b1.HasKey("CollectionId")
.HasName("pk_collections");
b1.ToTable("collections");
b1.ToJson("poster");
b1.WithOwner()
.HasForeignKey("CollectionId")
.HasConstraintName("fk_collections_collections_id");
.HasConstraintName("fk_collections_collections_collection_id");
});
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{
b1.Property<Guid>("CollectionId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("thumbnail_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("thumbnail_source");
.HasColumnType("text");
b1.HasKey("CollectionId");
b1.ToTable("collections");
b1.ToJson("thumbnail");
b1.WithOwner()
.HasForeignKey("CollectionId")
.HasConstraintName("fk_collections_collections_id");
@ -831,24 +838,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{
b1.Property<Guid>("EpisodeId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("logo_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("logo_source");
.HasColumnType("text");
b1.HasKey("EpisodeId");
b1.ToTable("episodes");
b1.ToJson("logo");
b1.WithOwner()
.HasForeignKey("EpisodeId")
.HasConstraintName("fk_episodes_episodes_id");
@ -857,24 +866,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{
b1.Property<Guid>("EpisodeId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("poster_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("poster_source");
.HasColumnType("text");
b1.HasKey("EpisodeId");
b1.ToTable("episodes");
b1.ToJson("poster");
b1.WithOwner()
.HasForeignKey("EpisodeId")
.HasConstraintName("fk_episodes_episodes_id");
@ -883,24 +894,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{
b1.Property<Guid>("EpisodeId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("thumbnail_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("thumbnail_source");
.HasColumnType("text");
b1.HasKey("EpisodeId");
b1.ToTable("episodes");
b1.ToJson("thumbnail");
b1.WithOwner()
.HasForeignKey("EpisodeId")
.HasConstraintName("fk_episodes_episodes_id");
@ -949,24 +962,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{
b1.Property<Guid>("MovieId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("logo_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("logo_source");
.HasColumnType("text");
b1.HasKey("MovieId");
b1.ToTable("movies");
b1.ToJson("logo");
b1.WithOwner()
.HasForeignKey("MovieId")
.HasConstraintName("fk_movies_movies_id");
@ -975,24 +990,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{
b1.Property<Guid>("MovieId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("poster_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("poster_source");
.HasColumnType("text");
b1.HasKey("MovieId");
b1.ToTable("movies");
b1.ToJson("poster");
b1.WithOwner()
.HasForeignKey("MovieId")
.HasConstraintName("fk_movies_movies_id");
@ -1001,24 +1018,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{
b1.Property<Guid>("MovieId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("thumbnail_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("thumbnail_source");
.HasColumnType("text");
b1.HasKey("MovieId");
b1.ToTable("movies");
b1.ToJson("thumbnail");
b1.WithOwner()
.HasForeignKey("MovieId")
.HasConstraintName("fk_movies_movies_id");
@ -1066,24 +1085,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{
b1.Property<Guid>("SeasonId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("logo_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("logo_source");
.HasColumnType("text");
b1.HasKey("SeasonId");
b1.ToTable("seasons");
b1.ToJson("logo");
b1.WithOwner()
.HasForeignKey("SeasonId")
.HasConstraintName("fk_seasons_seasons_id");
@ -1092,24 +1113,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{
b1.Property<Guid>("SeasonId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("poster_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("poster_source");
.HasColumnType("text");
b1.HasKey("SeasonId");
b1.ToTable("seasons");
b1.ToJson("poster");
b1.WithOwner()
.HasForeignKey("SeasonId")
.HasConstraintName("fk_seasons_seasons_id");
@ -1118,24 +1141,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{
b1.Property<Guid>("SeasonId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("thumbnail_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("thumbnail_source");
.HasColumnType("text");
b1.HasKey("SeasonId");
b1.ToTable("seasons");
b1.ToJson("thumbnail");
b1.WithOwner()
.HasForeignKey("SeasonId")
.HasConstraintName("fk_seasons_seasons_id");
@ -1161,24 +1186,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{
b1.Property<Guid>("ShowId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("logo_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("logo_source");
.HasColumnType("text");
b1.HasKey("ShowId");
b1.ToTable("shows");
b1.ToJson("logo");
b1.WithOwner()
.HasForeignKey("ShowId")
.HasConstraintName("fk_shows_shows_id");
@ -1187,24 +1214,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{
b1.Property<Guid>("ShowId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("poster_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("poster_source");
.HasColumnType("text");
b1.HasKey("ShowId");
b1.ToTable("shows");
b1.ToJson("poster");
b1.WithOwner()
.HasForeignKey("ShowId")
.HasConstraintName("fk_shows_shows_id");
@ -1213,24 +1242,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{
b1.Property<Guid>("ShowId")
.HasColumnType("uuid")
.HasColumnName("id");
.HasColumnType("uuid");
b1.Property<string>("Blurhash")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("thumbnail_blurhash");
.HasColumnType("character varying(32)");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("thumbnail_source");
.HasColumnType("text");
b1.HasKey("ShowId");
b1.ToTable("shows");
b1.ToJson("thumbnail");
b1.WithOwner()
.HasForeignKey("ShowId")
.HasConstraintName("fk_shows_shows_id");

View File

@ -94,6 +94,7 @@ public class PostgresContext(DbContextOptions options, IHttpContextAccessor acce
typeof(Dictionary<string, ExternalToken>),
new JsonTypeHandler<Dictionary<string, ExternalToken>>()
);
SqlMapper.AddTypeHandler(typeof(Image), new JsonTypeHandler<Image>());
SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>());
SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>());
SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler());

View File

@ -35,7 +35,7 @@ public static class RabbitMqModule
UserName = builder.Configuration.GetValue("RABBITMQ_DEFAULT_USER", "guest"),
Password = builder.Configuration.GetValue("RABBITMQ_DEFAULT_PASS", "guest"),
HostName = builder.Configuration.GetValue("RABBITMQ_HOST", "rabbitmq"),
Port = 5672,
Port = builder.Configuration.GetValue("RABBITMQ_Port", 5672),
};
return factory.CreateConnection();

View File

@ -30,7 +30,7 @@ services:
migrations:
condition: service_completed_successfully
volumes:
- kyoo:/kyoo
- kyoo:/metadata
migrations:
build:

View File

@ -42,7 +42,7 @@ services:
volumes:
- ./back:/app
- /app/out/
- kyoo:/kyoo
- kyoo:/metadata
migrations:
build:

View File

@ -31,7 +31,7 @@ services:
migrations:
condition: service_completed_successfully
volumes:
- kyoo:/kyoo
- kyoo:/metadata
migrations:
image: zoriya/kyoo_migrations:latest

View File

@ -19,10 +19,11 @@
*/
import { z } from "zod";
import { withImages, ResourceP } from "../traits";
import { ImagesP, ResourceP } from "../traits";
export const CollectionP = withImages(
ResourceP("collection").extend({
export const CollectionP = ResourceP("collection")
.merge(ImagesP)
.extend({
/**
* The title of this collection.
*/
@ -31,11 +32,11 @@ export const CollectionP = withImages(
* The summary of this show.
*/
overview: z.string().nullable(),
}),
).transform((x) => ({
...x,
href: `/collection/${x.slug}`,
}));
})
.transform((x) => ({
...x,
href: `/collection/${x.slug}`,
}));
/**
* A class representing collections of show or movies.

View File

@ -20,11 +20,12 @@
import { z } from "zod";
import { zdate } from "../utils";
import { withImages, imageFn } from "../traits";
import { ImagesP, imageFn } from "../traits";
import { ResourceP } from "../traits/resource";
export const BaseEpisodeP = withImages(
ResourceP("episode").extend({
export const BaseEpisodeP = ResourceP("episode")
.merge(ImagesP)
.extend({
/**
* The season in witch this episode is in.
*/
@ -71,8 +72,7 @@ export const BaseEpisodeP = withImages(
* The id of the show containing this episode
*/
showId: z.string(),
}),
)
})
.transform((x) => ({
...x,
runtime: x.runtime === 0 ? null : x.runtime,

View File

@ -20,7 +20,7 @@
import { z } from "zod";
import { zdate } from "../utils";
import { withImages, ResourceP, imageFn } from "../traits";
import { ImagesP, ResourceP, imageFn } from "../traits";
import { Genre } from "./genre";
import { StudioP } from "./studio";
import { Status } from "./show";
@ -28,8 +28,9 @@ import { CollectionP } from "./collection";
import { MetadataP } from "./metadata";
import { WatchStatusP } from "./watch-status";
export const MovieP = withImages(
ResourceP("movie").extend({
export const MovieP = ResourceP("movie")
.merge(ImagesP)
.extend({
/**
* The title of this movie.
*/
@ -104,8 +105,7 @@ export const MovieP = withImages(
* Metadata of what an user as started/planned to watch.
*/
watchStatus: WatchStatusP.optional().nullable(),
}),
)
})
.transform((x) => ({
...x,
runtime: x.runtime === 0 ? null : x.runtime,

View File

@ -19,28 +19,25 @@
*/
import { z } from "zod";
import { withImages } from "../traits";
import { ResourceP } from "../traits/resource";
import { ImagesP, ResourceP } from "../traits";
export const PersonP = withImages(
ResourceP("people").extend({
/**
* The name of this person.
*/
name: z.string(),
/**
* The type of work the person has done for the show. That can be something like "Actor",
* "Writer", "Music", "Voice Actor"...
*/
type: z.string().optional(),
export const PersonP = ResourceP("people").merge(ImagesP).extend({
/**
* The name of this person.
*/
name: z.string(),
/**
* The type of work the person has done for the show. That can be something like "Actor",
* "Writer", "Music", "Voice Actor"...
*/
type: z.string().optional(),
/**
* The role the People played. This is mostly used to inform witch character was played for actor
* and voice actors.
*/
role: z.string().optional(),
}),
);
/**
* The role the People played. This is mostly used to inform witch character was played for actor
* and voice actors.
*/
role: z.string().optional(),
});
/**
* A studio that make shows.

View File

@ -20,37 +20,34 @@
import { z } from "zod";
import { zdate } from "../utils";
import { withImages } from "../traits";
import { ResourceP } from "../traits/resource";
import { ImagesP, ResourceP } from "../traits";
export const SeasonP = withImages(
ResourceP("season").extend({
/**
* The name of this season.
*/
name: z.string(),
/**
* The number of this season. This can be set to 0 to indicate specials.
*/
seasonNumber: z.number(),
/**
* A quick overview of this season.
*/
overview: z.string().nullable(),
/**
* The starting air date of this season.
*/
startDate: zdate().nullable(),
/**
* The ending date of this season.
*/
endDate: zdate().nullable(),
/**
* The number of episodes available on kyoo of this season.
*/
episodesCount: z.number(),
}),
);
export const SeasonP = ResourceP("season").merge(ImagesP).extend({
/**
* The name of this season.
*/
name: z.string(),
/**
* The number of this season. This can be set to 0 to indicate specials.
*/
seasonNumber: z.number(),
/**
* A quick overview of this season.
*/
overview: z.string().nullable(),
/**
* The starting air date of this season.
*/
startDate: zdate().nullable(),
/**
* The ending date of this season.
*/
endDate: zdate().nullable(),
/**
* The number of episodes available on kyoo of this season.
*/
episodesCount: z.number(),
});
/**
* A season of a Show.

View File

@ -20,7 +20,7 @@
import { z } from "zod";
import { zdate } from "../utils";
import { withImages, ResourceP } from "../traits";
import { ImagesP, ResourceP } from "../traits";
import { Genre } from "./genre";
import { StudioP } from "./studio";
import { BaseEpisodeP } from "./episode.base";
@ -37,8 +37,9 @@ export enum Status {
Planned = "Planned",
}
export const ShowP = withImages(
ResourceP("show").extend({
export const ShowP = ResourceP("show")
.merge(ImagesP)
.extend({
/**
* The title of this show.
*/
@ -103,8 +104,7 @@ export const ShowP = withImages(
* The number of episodes in this show.
*/
episodesCount: z.number().int().gte(0).optional(),
}),
)
})
.transform((x) => {
if (!x.thumbnail && x.poster) {
x.thumbnail = { ...x.poster };

View File

@ -19,7 +19,7 @@
*/
import { Platform } from "react-native";
import { ZodObject, ZodRawShape, z } from "zod";
import { z } from "zod";
import { lastUsedUrl } from "..";
export const imageFn = (url: string) =>
@ -28,9 +28,12 @@ export const imageFn = (url: string) =>
export const Img = z.object({
source: z.string(),
blurhash: z.string(),
low: z.string().transform(imageFn),
medium: z.string().transform(imageFn),
high: z.string().transform(imageFn),
});
const ImagesP = z.object({
export const ImagesP = z.object({
/**
* An url to the poster of this resource. If this resource does not have an image, the link will
* be null. If the kyoo's instance is not capable of handling this kind of image for the specific
@ -53,28 +56,7 @@ const ImagesP = z.object({
logo: Img.nullable(),
});
const addQualities = (x: object | null | undefined, href: string) => {
if (x === null) return null;
return {
...x,
low: imageFn(`${href}?quality=low`),
medium: imageFn(`${href}?quality=medium`),
high: imageFn(`${href}?quality=high`),
};
};
export const withImages = <T extends ZodRawShape>(parser: ZodObject<T>) => {
return parser.merge(ImagesP).transform((x) => {
return {
...x,
poster: addQualities(x.poster, `/${x.kind}/${x.slug}/poster`),
thumbnail: addQualities(x.thumbnail, `/${x.kind}/${x.slug}/thumbnail`),
logo: addQualities(x.logo, `/${x.kind}/${x.slug}/logo`),
};
});
};
/**
* Base traits for items that has image resources.
*/
export type KyooImage = z.infer<typeof Img> & { low: string; medium: string; high: string };
export type KyooImage = z.infer<typeof Img>;

View File

@ -208,7 +208,7 @@ export const toTimerString = (timer?: number, duration?: number) => {
return "??:??";
const h = Math.floor(timer / 3600);
const min = Math.floor((timer / 60) % 60);
const sec = Math.round(timer % 60);
const sec = Math.floor(timer % 60);
const fmt = (n: number) => n.toString().padStart(2, "0");
if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`;

View File

@ -14,5 +14,5 @@ async def main():
async with KyooClient() as kyoo, Subscriber() as sub:
provider = Provider.get_default(kyoo.client)
scanner = Matcher(kyoo, provider)
await sub.listen(scanner)
matcher = Matcher(kyoo, provider)
await sub.listen(matcher)

View File

@ -1,4 +1,5 @@
from datetime import timedelta
from typing import Literal
import asyncio
from logging import getLogger
from providers.provider import Provider, ProviderError
@ -165,3 +166,32 @@ class Matcher:
return await self._client.post("seasons", data=season.to_kyoo())
return await create_season(show_id, season_number)
async def refresh(
self,
kind: Literal["collection", "movie", "episode", "show", "season"],
kyoo_id: str,
):
identify_table = {
"collection": lambda _, id: self._provider.identify_collection(
id["dataId"]
),
"movie": lambda _, id: self._provider.identify_movie(id["dataId"]),
"show": lambda _, id: self._provider.identify_show(id["dataId"]),
"season": lambda season, id: self._provider.identify_season(
id["dataId"], season["seasonNumber"]
),
"episode": lambda episode, id: self._provider.identify_episode(
id["showId"], id["season"], id["episode"], episode["absoluteNumber"]
),
}
current = await self._client.get(kind, kyoo_id)
if self._provider.name not in current["externalId"]:
logger.error(
f"Could not refresh metadata of {kind}/{kyoo_id}. Missisg provider id."
)
return False
provider_id = current["externalId"][self._provider.name]
new_value = await identify_table[kind](current, provider_id)
await self._client.put(f"{kind}/{kyoo_id}", data=new_value.to_kyoo())
return True

View File

@ -1,7 +1,6 @@
import asyncio
from dataclasses import dataclass
from dataclasses_json import DataClassJsonMixin
from typing import Literal
from typing import Union, Literal
from msgspec import Struct, json
import os
import logging
from aio_pika import connect_robust
@ -12,18 +11,33 @@ from matcher.matcher import Matcher
logger = logging.getLogger(__name__)
@dataclass
class Message(DataClassJsonMixin):
action: Literal["scan", "delete"]
class Message(Struct, tag_field="action", tag=str.lower):
pass
class Scan(Message):
path: str
class Delete(Message):
path: str
class Refresh(Message):
kind: Literal["collection", "show", "movie", "season", "episode"]
id: str
decoder = json.Decoder(Union[Scan, Delete, Refresh])
class Subscriber:
QUEUE = "scanner"
async def __aenter__(self):
self._con = await connect_robust(
host=os.environ.get("RABBITMQ_HOST", "rabbitmq"),
port=int(os.environ.get("RABBITMQ_PORT", "5672")),
login=os.environ.get("RABBITMQ_DEFAULT_USER", "guest"),
password=os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"),
)
@ -36,18 +50,24 @@ class Subscriber:
async def listen(self, scanner: Matcher):
async def on_message(message: AbstractIncomingMessage):
msg = Message.from_json(message.body)
ack = False
match msg.action:
case "scan":
ack = await scanner.identify(msg.path)
case "delete":
ack = await scanner.delete(msg.path)
case _:
logger.error(f"Invalid action: {msg.action}")
if ack:
await message.ack()
else:
try:
msg = decoder.decode(message.body)
ack = False
match msg:
case Scan(path):
ack = await scanner.identify(path)
case Delete(path):
ack = await scanner.delete(path)
case Refresh(kind, id):
ack = await scanner.refresh(kind, id)
case _:
logger.error(f"Invalid action: {msg.action}")
if ack:
await message.ack()
else:
await message.reject()
except Exception as e:
logger.exception("Unhandled error", exc_info=e)
await message.reject()
# Allow up to 20 scan requests to run in parallel on the same listener.

View File

@ -36,25 +36,12 @@ class KyooClient:
await self.client.close()
async def get_registered_paths(self) -> List[str]:
paths = None
async with self.client.get(
f"{self._url}/episodes",
params={"limit": 0},
f"{self._url}/paths",
headers={"X-API-Key": self._api_key},
) as r:
r.raise_for_status()
ret = await r.json()
paths = list(x["path"] for x in ret["items"])
async with self.client.get(
f"{self._url}/movies",
params={"limit": 0},
headers={"X-API-Key": self._api_key},
) as r:
r.raise_for_status()
ret = await r.json()
paths += list(x["path"] for x in ret["items"])
return paths
return await r.json()
async def create_issue(self, path: str, issue: str, extra: dict | None = None):
async with self.client.post(
@ -112,20 +99,6 @@ class KyooClient:
logger.error(f"Request error: {await r.text()}")
r.raise_for_status()
ret = await r.json()
if r.status == 409 and (
(path == "shows" and ret["startAir"][:4] != str(data["start_air"].year))
or (
path == "movies"
and ret["airDate"][:4] != str(data["air_date"].year)
)
):
logger.info(
f"Found a {path} with the same slug ({ret['slug']}) and a different date, using the date as part of the slug"
)
year = (data["start_air"] if path == "movie" else data["air_date"]).year
data["slug"] = f"{ret['slug']}-{year}"
return await self.post(path, data=data)
return ret["id"]
async def delete(
@ -154,3 +127,35 @@ class KyooClient:
r.raise_for_status()
await self.delete_issue(path)
async def get(
self, kind: Literal["movie", "show", "season", "episode", "collection"], id: str
):
async with self.client.get(
f"{self._url}/{kind}/{id}",
headers={"X-API-Key": self._api_key},
) as r:
if not r.ok:
logger.error(f"Request error: {await r.text()}")
r.raise_for_status()
return await r.json()
async def put(self, path: str, *, data: dict[str, Any]):
logger.debug(
"Sending %s: %s",
path,
jsons.dumps(
data,
key_transformer=jsons.KEY_TRANSFORMER_CAMELCASE,
jdkwargs={"indent": 4},
),
)
async with self.client.put(
f"{self._url}/{path}",
json=data,
headers={"X-API-Key": self._api_key},
) as r:
# Allow 409 and continue as if it worked.
if not r.ok and r.status != 409:
logger.error(f"Request error: {await r.text()}")
r.raise_for_status()

View File

@ -3,4 +3,4 @@ aiohttp
jsons
watchfiles
aio-pika
dataclasses-json
msgspec

View File

@ -7,7 +7,7 @@ logger = getLogger(__name__)
async def monitor(path: str, publisher: Publisher):
async for changes in awatch(path):
async for changes in awatch(path, ignore_permission_denied=True):
for event, file in changes:
if event == Change.added:
await publisher.add(file)

View File

@ -9,6 +9,7 @@ class Publisher:
async def __aenter__(self):
self._con = await connect_robust(
host=os.environ.get("RABBITMQ_HOST", "rabbitmq"),
port=int(os.environ.get("RABBITMQ_PORT", "5672")),
login=os.environ.get("RABBITMQ_DEFAULT_USER", "guest"),
password=os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"),
)

View File

@ -1 +0,0 @@
aio-pika

View File

@ -9,6 +9,7 @@
aio-pika
requests
dataclasses-json
msgspec
]);
dotnet = with pkgs.dotnetCorePackages;
combinePackages [