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" MEILI_MASTER_KEY="ghvjkgisbgkbgskegblfqbgjkebbhgwkjfb"
RABBITMQ_HOST=rabbitmq RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_DEFAULT_USER=kyoo RABBITMQ_DEFAULT_USER=kyoo
RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha

View File

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

View File

@ -56,7 +56,7 @@ class Simkl(Service):
] ]
}, },
headers={ 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, "simkl-api-key": self._api_key,
}, },
) )
@ -85,7 +85,7 @@ class Simkl(Service):
] ]
}, },
headers={ 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, "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 RUN apt-get update && apt-get install -y curl
COPY --from=builder /app /app COPY --from=builder /app /app
WORKDIR /kyoo WORKDIR /app
EXPOSE 5000 EXPOSE 5000
# The back can take a long time to start if meilisearch is initializing # 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 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 COPY src/Kyoo.Swagger/Kyoo.Swagger.csproj src/Kyoo.Swagger/Kyoo.Swagger.csproj
RUN dotnet restore RUN dotnet restore
WORKDIR /kyoo WORKDIR /app
EXPOSE 5000 EXPOSE 5000
ENV DOTNET_USE_POLLING_FILE_WATCHER 1 ENV DOTNET_USE_POLLING_FILE_WATCHER 1
# HEALTHCHECK --interval=5s CMD curl --fail http://localhost:5000/health || exit # HEALTHCHECK --interval=5s CMD curl --fail http://localhost:5000/health || exit

View File

@ -19,7 +19,10 @@ RUN dotnet restore -a $TARGETARCH
COPY . . COPY . .
RUN dotnet build 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 FROM mcr.microsoft.com/dotnet/runtime-deps:8.0
COPY --from=builder /app/migrate /app/migrate COPY --from=builder /app/migrate /app/migrate

View File

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

View File

@ -23,56 +23,19 @@ using Kyoo.Abstractions.Models;
namespace Kyoo.Abstractions.Controllers; namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// Download images and retrieve the path of those images for a resource.
/// </summary>
public interface IThumbnailsManager 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) Task DownloadImages<T>(T item)
where T : IThumbnails; where T : IThumbnails;
/// <summary> Task DownloadImage(Image? image, string what);
/// Retrieve the local path of an image of the given item.
/// </summary> string GetImagePath(Guid imageId, ImageQuality quality);
/// <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;
/// <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) Task DeleteImages<T>(T item)
where T : IThumbnails; 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); 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); Task SetUserImage(Guid userId, Stream? image);
} }

View File

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

View File

@ -17,12 +17,9 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System; using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models; namespace Kyoo.Abstractions.Models;
@ -49,9 +46,13 @@ public interface IThumbnails
} }
[JsonConverter(typeof(ImageConvertor))] [JsonConverter(typeof(ImageConvertor))]
[SqlFirstColumn(nameof(Source))]
public class Image public class Image
{ {
/// <summary>
/// A unique identifier for the image. Used for proper http caches.
/// </summary>
public Guid Id { get; set; }
/// <summary> /// <summary>
/// The original image from another server. /// The original image from another server.
/// </summary> /// </summary>
@ -63,6 +64,21 @@ public class Image
[MaxLength(32)] [MaxLength(32)]
public string Blurhash { get; set; } 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() { } public Image() { }
[JsonConstructor] [JsonConstructor]
@ -72,6 +88,7 @@ public class Image
Blurhash = blurhash ?? "000000"; Blurhash = blurhash ?? "000000";
} }
//
public class ImageConvertor : JsonConverter<Image> public class ImageConvertor : JsonConverter<Image>
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -84,7 +101,10 @@ public class Image
if (reader.TokenType == JsonTokenType.String && reader.GetString() is string source) if (reader.TokenType == JsonTokenType.String && reader.GetString() is string source)
return new Image(source); return new Image(source);
using JsonDocument document = JsonDocument.ParseValue(ref reader); 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 /> /// <inheritdoc />
@ -97,6 +117,9 @@ public class Image
writer.WriteStartObject(); writer.WriteStartObject();
writer.WriteString("source", value.Source); writer.WriteString("source", value.Source);
writer.WriteString("blurhash", value.Blurhash); writer.WriteString("blurhash", value.Blurhash);
writer.WriteString("low", value.Low);
writer.WriteString("medium", value.Medium);
writer.WriteString("high", value.High);
writer.WriteEndObject(); 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. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
/// </summary> /// </summary>
public const string AdminGroup = "4:Admin"; 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 System.Text.Json.Serialization.Metadata;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
using Microsoft.AspNetCore.Http;
using static System.Text.Json.JsonNamingPolicy; using static System.Text.Json.JsonNamingPolicy;
namespace Kyoo.Utils; 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; 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> /// <summary>
/// Get the generic definition of <paramref name="genericType"/>. /// 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; /// 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); .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> /// <summary>
/// Convert a dictionary to a query string. /// Convert a dictionary to a query string.
/// </summary> /// </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> /// <summary>
/// A local repository to handle collections /// A local repository to handle collections
/// </summary> /// </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 /> /// <inheritdoc />
public override async Task<ICollection<Collection>> Search( public override async Task<ICollection<Collection>> Search(
string query, string query,
Include<Collection>? include = default 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}%")) .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20) .Take(20)
.ToListAsync(); .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 /> /// <inheritdoc />
protected override async Task Validate(Collection resource) protected override async Task Validate(Collection resource)
{ {
@ -78,25 +53,18 @@ public class CollectionRepository : LocalRepository<Collection>
if (string.IsNullOrEmpty(resource.Name)) if (string.IsNullOrEmpty(resource.Name))
throw new ArgumentException("The collection's name must be set and not empty"); 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) public async Task AddMovie(Guid id, Guid movieId)
{ {
_database.AddLinks<Collection, Movie>(id, movieId); Database.AddLinks<Collection, Movie>(id, movieId);
await _database.SaveChangesAsync(); await Database.SaveChangesAsync();
} }
public async Task AddShow(Guid id, Guid showId) public async Task AddShow(Guid id, Guid showId)
{ {
_database.AddLinks<Collection, Show>(id, showId); Database.AddLinks<Collection, Show>(id, showId);
await _database.SaveChangesAsync(); await Database.SaveChangesAsync();
}
/// <inheritdoc />
public override async Task Delete(Collection obj)
{
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
} }
} }

View File

@ -252,7 +252,7 @@ public static class DapperHelper
this IDbConnection db, this IDbConnection db,
FormattableString command, FormattableString command,
Dictionary<string, Type> config, Dictionary<string, Type> config,
Func<List<object?>, T> mapper, Func<IList<object?>, T> mapper,
Func<Guid, Task<T>> get, Func<Guid, Task<T>> get,
SqlVariableContext context, SqlVariableContext context,
Include<T>? include, Include<T>? include,
@ -327,23 +327,6 @@ public static class DapperHelper
? ExpendProjections(typeV, prefix, include) ? ExpendProjections(typeV, prefix, include)
: null; : 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)) if (string.IsNullOrEmpty(projection))
return leadingComa; return leadingComa;
return $", {projection}{leadingComa}"; return $", {projection}{leadingComa}";
@ -355,19 +338,7 @@ public static class DapperHelper
types.ToArray(), types.ToArray(),
items => items =>
{ {
List<object?> nItems = new(items.Length); return mapIncludes(mapper(items), items.Skip(config.Count));
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));
}, },
ParametersDictionary.LoadFrom(cmd), ParametersDictionary.LoadFrom(cmd),
splitOn: string.Join( splitOn: string.Join(
@ -384,7 +355,7 @@ public static class DapperHelper
this IDbConnection db, this IDbConnection db,
FormattableString command, FormattableString command,
Dictionary<string, Type> config, Dictionary<string, Type> config,
Func<List<object?>, T> mapper, Func<IList<object?>, T> mapper,
SqlVariableContext context, SqlVariableContext context,
Include<T>? include, Include<T>? include,
Filter<T>? filter, 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 Dictionary<string, Type> Config { get; }
protected abstract T Mapper(List<object?> items); protected abstract T Mapper(IList<object?> items);
protected DbConnection Database { get; init; } protected DbConnection Database { get; init; }

View File

@ -18,6 +18,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
@ -35,8 +36,8 @@ namespace Kyoo.Core.Controllers;
public class EpisodeRepository( public class EpisodeRepository(
DatabaseContext database, DatabaseContext database,
IRepository<Show> shows, IRepository<Show> shows,
IThumbnailsManager thumbs IThumbnailsManager thumbnails
) : LocalRepository<Episode>(database, thumbs) ) : GenericRepository<Episode>(database)
{ {
static EpisodeRepository() static EpisodeRepository()
{ {
@ -64,70 +65,77 @@ public class EpisodeRepository(
Include<Episode>? include = default Include<Episode>? include = default
) )
{ {
return await AddIncludes(database.Episodes, include) return await AddIncludes(Database.Episodes, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20) .Take(20)
.ToListAsync(); .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 /> /// <inheritdoc />
public override async Task<Episode> Create(Episode obj) 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; obj.ShowSlug = obj.Show?.Slug ?? (await shows.Get(obj.ShowId)).Slug;
await base.Create(obj); return await base.Create(obj);
database.Entry(obj).State = EntityState.Added;
await database.SaveChangesAsync(() => GetDuplicated(obj));
await IRepository<Episode>.OnResourceCreated(obj);
return obj;
} }
/// <inheritdoc /> /// <inheritdoc />
protected override async Task Validate(Episode resource) protected override async Task Validate(Episode resource)
{ {
await base.Validate(resource); await base.Validate(resource);
resource.Show = null;
if (resource.ShowId == Guid.Empty) if (resource.ShowId == Guid.Empty)
{ throw new ValidationException("Missing show id");
if (resource.Show == null) resource.Season = null;
{
throw new ArgumentException(
$"Can't store an episode not related "
+ $"to any show (showID: {resource.ShowId})."
);
}
resource.ShowId = resource.Show.Id;
}
if (resource.SeasonId == null && resource.SeasonNumber != null) if (resource.SeasonId == null && resource.SeasonNumber != null)
{ {
resource.Season = await database.Seasons.FirstOrDefaultAsync(x => resource.SeasonId = await Database
x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber .Seasons.Where(x =>
); x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
)
.Select(x => x.Id)
.FirstOrDefaultAsync();
} }
await thumbnails.DownloadImages(resource);
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task Delete(Episode obj) public override async Task Delete(Episode obj)
{ {
int epCount = await database int epCount = await Database
.Episodes.Where(x => x.ShowId == obj.ShowId) .Episodes.Where(x => x.ShowId == obj.ShowId)
.Take(2) .Take(2)
.CountAsync(); .CountAsync();
database.Entry(obj).State = EntityState.Deleted;
await database.SaveChangesAsync();
await base.Delete(obj);
if (epCount == 1) if (epCount == 1)
await shows.Delete(obj.ShowId); 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
@ -29,38 +30,14 @@ using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql; using Kyoo.Postgresql;
using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers; namespace Kyoo.Core.Controllers;
/// <summary> public abstract class GenericRepository<T>(DatabaseContext database) : IRepository<T>
/// 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>
where T : class, IResource, IQuery where T : class, IResource, IQuery
{ {
/// <summary> public DatabaseContext Database => database;
/// 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;
}
/// <inheritdoc/> /// <inheritdoc/>
public Type RepositoryType => typeof(T); public Type RepositoryType => typeof(T);
@ -127,12 +104,6 @@ public abstract class LocalRepository<T> : IRepository<T>
return query; 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) protected virtual async Task<T> GetWithTracking(Guid id)
{ {
T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == 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; return ret;
} }
protected virtual Task<T?> GetDuplicated(T item)
{
return GetOrDefault(item.Slug);
}
/// <inheritdoc /> /// <inheritdoc />
public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default) 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) public virtual async Task<T> Create(T obj)
{ {
await Validate(obj); await Validate(obj);
if (obj is IThumbnails thumbs) Database.Add(obj);
{ await Database.SaveChangesAsync(() => Get(obj.Slug));
try await IRepository<T>.OnResourceCreated(obj);
{
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;
}
return obj; return obj;
} }
@ -346,27 +295,11 @@ public abstract class LocalRepository<T> : IRepository<T>
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<T> Edit(T edited) public virtual async Task<T> Edit(T edited)
{ {
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; await Validate(edited);
Database.ChangeTracker.LazyLoadingEnabled = false; Database.Update(edited);
try await Database.SaveChangesAsync();
{ await IRepository<T>.OnResourceEdited(edited);
T old = await GetWithTracking(edited.Id); return edited;
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();
}
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -391,39 +324,9 @@ public abstract class LocalRepository<T> : IRepository<T>
} }
} }
/// <summary> /// <exception cref="ValidationException">
/// 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">
/// You can throw this if the resource is illegal and should not be saved. /// You can throw this if the resource is illegal and should not be saved.
/// </exception> /// </exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task Validate(T resource) protected virtual Task Validate(T resource)
{ {
if ( if (
@ -432,26 +335,9 @@ public abstract class LocalRepository<T> : IRepository<T>
) )
return Task.CompletedTask; return Task.CompletedTask;
if (string.IsNullOrEmpty(resource.Slug)) if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("Resource can't have null as a slug."); throw new ValidationException("Resource can't have null as a slug.");
if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random") if (resource.Slug == "random")
{ throw new ValidationException("Resources slug can't be the literal \"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\"."
);
}
}
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -470,18 +356,20 @@ public abstract class LocalRepository<T> : IRepository<T>
} }
/// <inheritdoc/> /// <inheritdoc/>
public virtual Task Delete(T obj) public virtual async Task Delete(T obj)
{ {
IRepository<T>.OnResourceDeleted(obj); await Database.Set<T>().Where(x => x.Id == obj.Id).ExecuteDeleteAsync();
if (obj is IThumbnails thumbs) await IRepository<T>.OnResourceDeleted(obj);
return _thumbs.DeleteImages(thumbs);
return Task.CompletedTask;
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task DeleteAll(Filter<T> filter) public virtual async Task DeleteAll(Filter<T> filter)
{ {
foreach (T resource in await GetAll(filter)) ICollection<T> items = await GetAll(filter);
await Delete(resource); 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> /// <summary>
/// A local repository to handle library items. /// A local repository to handle library items.
/// </summary> /// </summary>
public class LibraryItemRepository : DapperRepository<ILibraryItem> public class LibraryItemRepository(DbConnection database, SqlVariableContext context)
: DapperRepository<ILibraryItem>(database, context)
{ {
// language=PostgreSQL // language=PostgreSQL
protected override FormattableString Sql => protected override FormattableString Sql =>
@ -67,7 +68,7 @@ public class LibraryItemRepository : DapperRepository<ILibraryItem>
{ "c", typeof(Collection) } { "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) if (items[0] is Show show && show.Id != Guid.Empty)
return show; return show;
@ -78,9 +79,6 @@ public class LibraryItemRepository : DapperRepository<ILibraryItem>
throw new InvalidDataException(); throw new InvalidDataException();
} }
public LibraryItemRepository(DbConnection database, SqlVariableContext context)
: base(database, context) { }
public async Task<ICollection<ILibraryItem>> GetAllOfCollection( public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
Guid collectionId, Guid collectionId,
Filter<ILibraryItem>? filter = default, Filter<ILibraryItem>? filter = default,

View File

@ -21,58 +21,48 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql; using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers; namespace Kyoo.Core.Controllers;
/// <summary> public class MovieRepository(
/// A local repository to handle shows DatabaseContext database,
/// </summary> IRepository<Studio> studios,
public class MovieRepository : LocalRepository<Movie> 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 /> /// <inheritdoc />
public override async Task<ICollection<Movie>> Search( public override async Task<ICollection<Movie>> Search(
string query, string query,
Include<Movie>? include = default 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}%")) .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20) .Take(20)
.ToListAsync(); .ToListAsync();
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<Movie> Create(Movie obj) public override Task<Movie> Create(Movie obj)
{ {
await base.Create(obj); try
_database.Entry(obj).State = EntityState.Added; {
await _database.SaveChangesAsync(() => Get(obj.Slug)); return base.Create(obj);
await IRepository<Movie>.OnResourceCreated(obj); }
return 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 /> /// <inheritdoc />
@ -81,28 +71,9 @@ public class MovieRepository : LocalRepository<Movie>
await base.Validate(resource); await base.Validate(resource);
if (resource.Studio != null) if (resource.Studio != null)
{ {
resource.Studio = await _studios.CreateIfNotExists(resource.Studio); resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id;
resource.StudioId = resource.Studio.Id; resource.Studio = null;
} }
} await thumbnails.DownloadImages(resource);
/// <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);
} }
} }

View File

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

View File

@ -31,16 +31,9 @@ using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Core.Controllers; namespace Kyoo.Core.Controllers;
/// <summary> public class SeasonRepository(DatabaseContext database, IThumbnailsManager thumbnails)
/// A local repository to handle seasons. : GenericRepository<Season>(database)
/// </summary>
public class SeasonRepository : LocalRepository<Season>
{ {
/// <summary>
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
static SeasonRepository() static SeasonRepository()
{ {
// Edit seasons slugs when the show's slug changes. // 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/> /// <inheritdoc/>
public override async Task<ICollection<Season>> Search( public override async Task<ICollection<Season>> Search(
string query, string query,
Include<Season>? include = default Include<Season>? include = default
) )
{ {
return await AddIncludes(_database.Seasons, include) return await AddIncludes(Database.Seasons, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20) .Take(20)
.ToListAsync(); .ToListAsync();
@ -94,38 +69,20 @@ public class SeasonRepository : LocalRepository<Season>
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<Season> Create(Season obj) public override async Task<Season> Create(Season obj)
{ {
await base.Create(obj); // Set it for the OnResourceCreated event and the return value.
obj.ShowSlug = 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}"); ?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
_database.Entry(obj).State = EntityState.Added; return await base.Create(obj);
await _database.SaveChangesAsync(() => GetDuplicated(obj));
await IRepository<Season>.OnResourceCreated(obj);
return obj;
} }
/// <inheritdoc/> /// <inheritdoc/>
protected override async Task Validate(Season resource) protected override async Task Validate(Season resource)
{ {
await base.Validate(resource); await base.Validate(resource);
resource.Show = null;
if (resource.ShowId == Guid.Empty) if (resource.ShowId == Guid.Empty)
{ throw new ValidationException("Missing show id");
if (resource.Show == null) await thumbnails.DownloadImages(resource);
{
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);
} }
} }

View File

@ -21,59 +21,48 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql; using Kyoo.Postgresql;
using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers; namespace Kyoo.Core.Controllers;
/// <summary> public class ShowRepository(
/// A local repository to handle shows DatabaseContext database,
/// </summary> IRepository<Studio> studios,
public class ShowRepository : LocalRepository<Show> 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 /> /// <inheritdoc />
public override async Task<ICollection<Show>> Search( public override async Task<ICollection<Show>> Search(
string query, string query,
Include<Show>? include = default 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}%")) .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20) .Take(20)
.ToListAsync(); .ToListAsync();
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<Show> Create(Show obj) public override Task<Show> Create(Show obj)
{ {
await base.Create(obj); try
_database.Entry(obj).State = EntityState.Added; {
await _database.SaveChangesAsync(() => Get(obj.Slug)); return base.Create(obj);
await IRepository<Show>.OnResourceCreated(obj); }
return 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 /> /// <inheritdoc />
@ -82,28 +71,9 @@ public class ShowRepository : LocalRepository<Show>
await base.Validate(resource); await base.Validate(resource);
if (resource.Studio != null) if (resource.Studio != null)
{ {
resource.Studio = await _studios.CreateIfNotExists(resource.Studio); resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id;
resource.StudioId = resource.Studio.Id; resource.Studio = null;
} }
} await thumbnails.DownloadImages(resource);
/// <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);
} }
} }

View File

@ -19,11 +19,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql; using Kyoo.Postgresql;
using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers; namespace Kyoo.Core.Controllers;
@ -31,51 +29,17 @@ namespace Kyoo.Core.Controllers;
/// <summary> /// <summary>
/// A local repository to handle studios /// A local repository to handle studios
/// </summary> /// </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 /> /// <inheritdoc />
public override async Task<ICollection<Studio>> Search( public override async Task<ICollection<Studio>> Search(
string query, string query,
Include<Studio>? include = default Include<Studio>? include = default
) )
{ {
return await AddIncludes(_database.Studios, include) return await AddIncludes(Database.Studios, include)
.Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name, $"%{query}%"))
.Take(20) .Take(20)
.ToListAsync(); .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, DatabaseContext database,
DbConnection db, DbConnection db,
SqlVariableContext context, SqlVariableContext context,
IThumbnailsManager thumbs,
PermissionOption options PermissionOption options
) : LocalRepository<User>(database, thumbs), IUserRepository ) : GenericRepository<User>(database), IUserRepository
{ {
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<User>> Search( public override async Task<ICollection<User>> Search(
@ -50,7 +49,7 @@ public class UserRepository(
Include<User>? include = default Include<User>? include = default
) )
{ {
return await AddIncludes(database.Users, include) return await AddIncludes(Database.Users, include)
.Where(x => EF.Functions.ILike(x.Username, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Username, $"%{query}%"))
.Take(20) .Take(20)
.ToListAsync(); .ToListAsync();
@ -60,26 +59,14 @@ public class UserRepository(
public override async Task<User> Create(User obj) public override async Task<User> Create(User obj)
{ {
// If no users exists, the new one will be an admin. Give it every permissions. // 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; obj.Permissions = PermissionOption.Admin;
else if (!options.RequireVerification) else if (!options.RequireVerification)
obj.Permissions = options.NewUser; obj.Permissions = options.NewUser;
else else
obj.Permissions = Array.Empty<string>(); obj.Permissions = Array.Empty<string>();
await base.Create(obj); return 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);
} }
public Task<User?> GetByExternalId(string provider, string id) public Task<User?> GetByExternalId(string provider, string id)
@ -109,8 +96,8 @@ public class UserRepository(
User user = await GetWithTracking(userId); User user = await GetWithTracking(userId);
user.ExternalId[provider] = token; user.ExternalId[provider] = token;
// without that, the change tracker does not find the modification. /shrug // without that, the change tracker does not find the modification. /shrug
database.Entry(user).Property(x => x.ExternalId).IsModified = true; Database.Entry(user).Property(x => x.ExternalId).IsModified = true;
await database.SaveChangesAsync(); await Database.SaveChangesAsync();
return user; return user;
} }
@ -119,8 +106,8 @@ public class UserRepository(
User user = await GetWithTracking(userId); User user = await GetWithTracking(userId);
user.ExternalId.Remove(provider); user.ExternalId.Remove(provider);
// without that, the change tracker does not find the modification. /shrug // without that, the change tracker does not find the modification. /shrug
database.Entry(user).Property(x => x.ExternalId).IsModified = true; Database.Entry(user).Property(x => x.ExternalId).IsModified = true;
await database.SaveChangesAsync(); await Database.SaveChangesAsync();
return user; return user;
} }
} }

View File

@ -135,7 +135,7 @@ public class WatchStatusRepository(
{ "_mw", typeof(MovieWatchStatus) }, { "_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) if (items[0] is Show show && show.Id != Guid.Empty)
{ {

View File

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

View File

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

View File

@ -17,9 +17,9 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System; using System;
using System.IO;
using Kyoo.Authentication; using Kyoo.Authentication;
using Kyoo.Core; using Kyoo.Core;
using Kyoo.Core.Controllers;
using Kyoo.Core.Extensions; using Kyoo.Core.Extensions;
using Kyoo.Meiliseach; using Kyoo.Meiliseach;
using Kyoo.Postgresql; using Kyoo.Postgresql;
@ -70,11 +70,6 @@ AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception"); Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception");
builder.Host.UseSerilog(); builder.Host.UseSerilog();
builder
.Services.AddMvcCore()
.AddApplicationPart(typeof(CoreModule).Assembly)
.AddApplicationPart(typeof(AuthenticationModule).Assembly);
builder.Services.ConfigureMvc(); builder.Services.ConfigureMvc();
builder.Services.ConfigureOpenApi(); builder.Services.ConfigureOpenApi();
builder.ConfigureKyoo(); builder.ConfigureKyoo();
@ -93,42 +88,6 @@ app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.MapControllers(); 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 // Activate services that always run in the background
app.Services.GetRequiredService<MeiliSync>(); app.Services.GetRequiredService<MeiliSync>();
app.Services.GetRequiredService<RabbitProducer>(); app.Services.GetRequiredService<RabbitProducer>();
@ -138,4 +97,7 @@ await using (AsyncServiceScope scope = app.Services.CreateAsyncScope())
await MeilisearchModule.Initialize(scope.ServiceProvider); 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")] [Route("health")]
[ApiController] [ApiController]
[ApiDefinition("Health")] [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> /// <summary>
/// Check if the api is ready to accept requests. /// Check if the api is ready to accept requests.
/// </summary> /// </summary>
@ -57,7 +46,7 @@ public class Health : BaseApi
headers.Pragma = "no-cache"; headers.Pragma = "no-cache";
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT"; headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
HealthReport result = await _healthCheckService.CheckHealthAsync(); HealthReport result = await healthCheckService.CheckHealthAsync();
return result.Status switch return result.Status switch
{ {
HealthStatus.Healthy => Ok(new HealthResult("Healthy")), 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); 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> /// <summary>
/// Patch /// Patch
/// </summary> /// </summary>

View File

@ -16,7 +16,7 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.IO; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
@ -28,14 +28,8 @@ using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api; 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] [ApiController]
public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thumbs) public class CrudThumbsApi<T>(IRepository<T> repository) : CrudApi<T>(repository)
: CrudApi<T>(repository)
where T : class, IResource, IThumbnails, IQuery where T : class, IResource, IThumbnails, IQuery
{ {
private async Task<IActionResult> _GetImage( private async Task<IActionResult> _GetImage(
@ -50,18 +44,19 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
); );
if (resource == null) if (resource == null)
return NotFound(); 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(); return NotFound();
if (!identifier.Match(id => false, slug => slug == "random")) // TODO: Remove the /api and use a proxy rewrite instead.
{ return Redirect($"/api/thumbnails/{img.Id}");
// 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);
} }
/// <summary> /// <summary>
@ -78,7 +73,7 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
/// </response> /// </response>
[HttpGet("{identifier:id}/poster")] [HttpGet("{identifier:id}/poster")]
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality) public Task<IActionResult> GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality)
{ {
@ -99,7 +94,7 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
/// </response> /// </response>
[HttpGet("{identifier:id}/logo")] [HttpGet("{identifier:id}/logo")]
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality) public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality)
{ {
@ -120,6 +115,8 @@ public class CrudThumbsApi<T>(IRepository<T> repository, IThumbnailsManager thum
/// </response> /// </response>
[HttpGet("{identifier:id}/thumbnail")] [HttpGet("{identifier:id}/thumbnail")]
[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)] [HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality) public Task<IActionResult> GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality)
{ {
return _GetImage(identifier, "thumbnail", quality); return _GetImage(identifier, "thumbnail", quality);

View File

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

View File

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

View File

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

View File

@ -34,23 +34,5 @@ namespace Kyoo.Core.Api;
[ApiController] [ApiController]
[PartialPermission("LibraryItem")] [PartialPermission("LibraryItem")]
[ApiDefinition("Items", Group = ResourcesGroup)] [ApiDefinition("Items", Group = ResourcesGroup)]
public class LibraryItemApi : CrudThumbsApi<ILibraryItem> public class LibraryItemApi(IRepository<ILibraryItem> libraryItems)
{ : CrudThumbsApi<ILibraryItem>(libraryItems) { }
/// <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;
}
}

View File

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

View File

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

View File

@ -38,26 +38,9 @@ namespace Kyoo.Core.Api;
[ApiController] [ApiController]
[PartialPermission(nameof(Season))] [PartialPermission(nameof(Season))]
[ApiDefinition("Seasons", Group = ResourcesGroup)] [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> /// <summary>
/// Get episodes in the season /// Get episodes in the season
/// </summary> /// </summary>
@ -86,7 +69,7 @@ public class SeasonApi : CrudThumbsApi<Season>
[FromQuery] Include<Episode> fields [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)), Filter.And(filter, identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)),
sortBy, sortBy,
fields, fields,
@ -95,7 +78,7 @@ public class SeasonApi : CrudThumbsApi<Season>
if ( if (
!resources.Any() !resources.Any()
&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null && await libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null
) )
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
@ -120,7 +103,7 @@ public class SeasonApi : CrudThumbsApi<Season>
[FromQuery] Include<Show> fields [FromQuery] Include<Show> fields
) )
{ {
Show? ret = await _libraryManager.Shows.GetOrDefault( Show? ret = await libraryManager.Shows.GetOrDefault(
identifier.IsContainedIn<Show, Season>(x => x.Seasons!), identifier.IsContainedIn<Show, Season>(x => x.Seasons!),
fields fields
); );

View File

@ -40,26 +40,8 @@ namespace Kyoo.Core.Api;
[ApiController] [ApiController]
[PartialPermission(nameof(Show))] [PartialPermission(nameof(Show))]
[ApiDefinition("Shows", Group = ResourcesGroup)] [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> /// <summary>
/// Get seasons of this show /// Get seasons of this show
/// </summary> /// </summary>
@ -88,7 +70,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Season> fields [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)), Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)),
sortBy, sortBy,
fields, fields,
@ -97,7 +79,7 @@ public class ShowApi : CrudThumbsApi<Show>
if ( if (
!resources.Any() !resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null && await libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
) )
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
@ -131,7 +113,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Episode> fields [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)), Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)),
sortBy, sortBy,
fields, fields,
@ -140,7 +122,7 @@ public class ShowApi : CrudThumbsApi<Show>
if ( if (
!resources.Any() !resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null && await libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
) )
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
@ -165,7 +147,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Studio> fields [FromQuery] Include<Studio> fields
) )
{ {
return await _libraryManager.Studios.Get( return await libraryManager.Studios.Get(
identifier.IsContainedIn<Studio, Show>(x => x.Shows!), identifier.IsContainedIn<Studio, Show>(x => x.Shows!),
fields fields
); );
@ -199,7 +181,7 @@ public class ShowApi : CrudThumbsApi<Show>
[FromQuery] Include<Collection> fields [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!)), Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)),
sortBy, sortBy,
fields, fields,
@ -208,7 +190,7 @@ public class ShowApi : CrudThumbsApi<Show>
if ( if (
!resources.Any() !resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null && await libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
) )
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
@ -233,9 +215,9 @@ public class ShowApi : CrudThumbsApi<Show>
{ {
Guid id = await identifier.Match( Guid id = await identifier.Match(
id => Task.FromResult(id), 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> /// <summary>
@ -260,9 +242,9 @@ public class ShowApi : CrudThumbsApi<Show>
{ {
Guid id = await identifier.Match( Guid id = await identifier.Match(
id => Task.FromResult(id), 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> /// <summary>
@ -283,8 +265,8 @@ public class ShowApi : CrudThumbsApi<Show>
{ {
Guid id = await identifier.Match( Guid id = await identifier.Match(
id => Task.FromResult(id), 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 // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // 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.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Meilisearch; using Meilisearch;

View File

@ -23,7 +23,6 @@ using System.Linq.Expressions;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Authentication; using Kyoo.Authentication;
@ -33,13 +32,6 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace Kyoo.Postgresql; 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 public abstract class DatabaseContext : DbContext
{ {
private readonly IHttpContextAccessor _accessor; private readonly IHttpContextAccessor _accessor;
@ -53,39 +45,18 @@ public abstract class DatabaseContext : DbContext
public Guid? CurrentUserId => _accessor.HttpContext?.User.GetId(); public Guid? CurrentUserId => _accessor.HttpContext?.User.GetId();
/// <summary>
/// All collections of Kyoo. See <see cref="Collection"/>.
/// </summary>
public DbSet<Collection> Collections { get; set; } public DbSet<Collection> Collections { get; set; }
/// <summary>
/// All movies of Kyoo. See <see cref="Movie"/>.
/// </summary>
public DbSet<Movie> Movies { get; set; } public DbSet<Movie> Movies { get; set; }
/// <summary>
/// All shows of Kyoo. See <see cref="Show"/>.
/// </summary>
public DbSet<Show> Shows { get; set; } public DbSet<Show> Shows { get; set; }
/// <summary>
/// All seasons of Kyoo. See <see cref="Season"/>.
/// </summary>
public DbSet<Season> Seasons { get; set; } public DbSet<Season> Seasons { get; set; }
/// <summary>
/// All episodes of Kyoo. See <see cref="Episode"/>.
/// </summary>
public DbSet<Episode> Episodes { get; set; } public DbSet<Episode> Episodes { get; set; }
/// <summary>
/// All studios of Kyoo. See <see cref="Studio"/>.
/// </summary>
public DbSet<Studio> Studios { get; set; } public DbSet<Studio> Studios { get; set; }
/// <summary>
/// The list of registered users.
/// </summary>
public DbSet<User> Users { get; set; } public DbSet<User> Users { get; set; }
public DbSet<MovieWatchStatus> MovieWatchStatus { get; set; } public DbSet<MovieWatchStatus> MovieWatchStatus { get; set; }
@ -129,28 +100,13 @@ public abstract class DatabaseContext : DbContext
_accessor = accessor; _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>() protected abstract string LinkName<T, T2>()
where T : IResource where T : IResource
where T2 : 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>() protected abstract string LinkNameFk<T>()
where T : IResource; 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) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);
@ -201,9 +157,9 @@ public abstract class DatabaseContext : DbContext
private static void _HasImages<T>(ModelBuilder modelBuilder) private static void _HasImages<T>(ModelBuilder modelBuilder)
where T : class, IThumbnails where T : class, IThumbnails
{ {
modelBuilder.Entity<T>().OwnsOne(x => x.Poster); modelBuilder.Entity<T>().OwnsOne(x => x.Poster, x => x.ToJson());
modelBuilder.Entity<T>().OwnsOne(x => x.Thumbnail); modelBuilder.Entity<T>().OwnsOne(x => x.Thumbnail, x => x.ToJson());
modelBuilder.Entity<T>().OwnsOne(x => x.Logo); modelBuilder.Entity<T>().OwnsOne(x => x.Logo, x => x.ToJson());
} }
private static void _HasAddedDate<T>(ModelBuilder modelBuilder) private static void _HasAddedDate<T>(ModelBuilder modelBuilder)
@ -227,15 +183,6 @@ public abstract class DatabaseContext : DbContext
.ValueGeneratedOnAdd(); .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>( private void _HasManyToMany<T, T2>(
ModelBuilder modelBuilder, ModelBuilder modelBuilder,
Expression<Func<T, IEnumerable<T2>?>> firstNavigation, 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@ -412,28 +355,6 @@ public abstract class DatabaseContext : DbContext
_HasJson<Issue, object>(modelBuilder, x => x.Extra); _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() public override int SaveChanges()
{ {
try 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) public override int SaveChanges(bool acceptAllChangesOnSuccess)
{ {
try 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( public override async Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default 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) public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
try 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>( public async Task<int> SaveChangesAsync<T>(
Func<Task<T>> getExisting, Func<Task<T>> getExisting,
CancellationToken cancellationToken = default 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) public async Task<int> SaveIfNoDuplicates(CancellationToken cancellationToken = default)
{ {
try 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) public T? LocalEntity<T>(string slug)
where T : class, IResource where T : class, IResource
{ {
return ChangeTracker.Entries<T>().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity; 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); protected abstract bool IsDuplicateException(Exception ex);
/// <summary>
/// Delete every changes that are on this context.
/// </summary>
public void DiscardChanges() public void DiscardChanges()
{ {
foreach ( 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 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{ {
b1.Property<Guid>("CollectionId") b1.Property<Guid>("CollectionId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("logo_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("logo_source");
b1.HasKey("CollectionId"); b1.HasKey("CollectionId");
b1.ToTable("collections"); b1.ToTable("collections");
b1.ToJson("logo");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("CollectionId") .HasForeignKey("CollectionId")
.HasConstraintName("fk_collections_collections_id"); .HasConstraintName("fk_collections_collections_id");
@ -757,50 +759,55 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{ {
b1.Property<Guid>("CollectionId") b1.Property<Guid>("CollectionId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("poster_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("poster_source");
b1.HasKey("CollectionId"); b1.HasKey("CollectionId")
.HasName("pk_collections");
b1.ToTable("collections"); b1.ToTable("collections");
b1.ToJson("poster");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("CollectionId") .HasForeignKey("CollectionId")
.HasConstraintName("fk_collections_collections_id"); .HasConstraintName("fk_collections_collections_collection_id");
}); });
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{ {
b1.Property<Guid>("CollectionId") b1.Property<Guid>("CollectionId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("thumbnail_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("thumbnail_source");
b1.HasKey("CollectionId"); b1.HasKey("CollectionId");
b1.ToTable("collections"); b1.ToTable("collections");
b1.ToJson("thumbnail");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("CollectionId") .HasForeignKey("CollectionId")
.HasConstraintName("fk_collections_collections_id"); .HasConstraintName("fk_collections_collections_id");
@ -831,24 +838,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{ {
b1.Property<Guid>("EpisodeId") b1.Property<Guid>("EpisodeId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("logo_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("logo_source");
b1.HasKey("EpisodeId"); b1.HasKey("EpisodeId");
b1.ToTable("episodes"); b1.ToTable("episodes");
b1.ToJson("logo");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("EpisodeId") .HasForeignKey("EpisodeId")
.HasConstraintName("fk_episodes_episodes_id"); .HasConstraintName("fk_episodes_episodes_id");
@ -857,24 +866,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{ {
b1.Property<Guid>("EpisodeId") b1.Property<Guid>("EpisodeId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("poster_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("poster_source");
b1.HasKey("EpisodeId"); b1.HasKey("EpisodeId");
b1.ToTable("episodes"); b1.ToTable("episodes");
b1.ToJson("poster");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("EpisodeId") .HasForeignKey("EpisodeId")
.HasConstraintName("fk_episodes_episodes_id"); .HasConstraintName("fk_episodes_episodes_id");
@ -883,24 +894,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{ {
b1.Property<Guid>("EpisodeId") b1.Property<Guid>("EpisodeId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("thumbnail_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("thumbnail_source");
b1.HasKey("EpisodeId"); b1.HasKey("EpisodeId");
b1.ToTable("episodes"); b1.ToTable("episodes");
b1.ToJson("thumbnail");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("EpisodeId") .HasForeignKey("EpisodeId")
.HasConstraintName("fk_episodes_episodes_id"); .HasConstraintName("fk_episodes_episodes_id");
@ -949,24 +962,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{ {
b1.Property<Guid>("MovieId") b1.Property<Guid>("MovieId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("logo_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("logo_source");
b1.HasKey("MovieId"); b1.HasKey("MovieId");
b1.ToTable("movies"); b1.ToTable("movies");
b1.ToJson("logo");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("MovieId") .HasForeignKey("MovieId")
.HasConstraintName("fk_movies_movies_id"); .HasConstraintName("fk_movies_movies_id");
@ -975,24 +990,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{ {
b1.Property<Guid>("MovieId") b1.Property<Guid>("MovieId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("poster_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("poster_source");
b1.HasKey("MovieId"); b1.HasKey("MovieId");
b1.ToTable("movies"); b1.ToTable("movies");
b1.ToJson("poster");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("MovieId") .HasForeignKey("MovieId")
.HasConstraintName("fk_movies_movies_id"); .HasConstraintName("fk_movies_movies_id");
@ -1001,24 +1018,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{ {
b1.Property<Guid>("MovieId") b1.Property<Guid>("MovieId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("thumbnail_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("thumbnail_source");
b1.HasKey("MovieId"); b1.HasKey("MovieId");
b1.ToTable("movies"); b1.ToTable("movies");
b1.ToJson("thumbnail");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("MovieId") .HasForeignKey("MovieId")
.HasConstraintName("fk_movies_movies_id"); .HasConstraintName("fk_movies_movies_id");
@ -1066,24 +1085,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{ {
b1.Property<Guid>("SeasonId") b1.Property<Guid>("SeasonId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("logo_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("logo_source");
b1.HasKey("SeasonId"); b1.HasKey("SeasonId");
b1.ToTable("seasons"); b1.ToTable("seasons");
b1.ToJson("logo");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("SeasonId") .HasForeignKey("SeasonId")
.HasConstraintName("fk_seasons_seasons_id"); .HasConstraintName("fk_seasons_seasons_id");
@ -1092,24 +1113,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{ {
b1.Property<Guid>("SeasonId") b1.Property<Guid>("SeasonId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("poster_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("poster_source");
b1.HasKey("SeasonId"); b1.HasKey("SeasonId");
b1.ToTable("seasons"); b1.ToTable("seasons");
b1.ToJson("poster");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("SeasonId") .HasForeignKey("SeasonId")
.HasConstraintName("fk_seasons_seasons_id"); .HasConstraintName("fk_seasons_seasons_id");
@ -1118,24 +1141,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{ {
b1.Property<Guid>("SeasonId") b1.Property<Guid>("SeasonId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("thumbnail_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("thumbnail_source");
b1.HasKey("SeasonId"); b1.HasKey("SeasonId");
b1.ToTable("seasons"); b1.ToTable("seasons");
b1.ToJson("thumbnail");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("SeasonId") .HasForeignKey("SeasonId")
.HasConstraintName("fk_seasons_seasons_id"); .HasConstraintName("fk_seasons_seasons_id");
@ -1161,24 +1186,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 =>
{ {
b1.Property<Guid>("ShowId") b1.Property<Guid>("ShowId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("logo_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("logo_source");
b1.HasKey("ShowId"); b1.HasKey("ShowId");
b1.ToTable("shows"); b1.ToTable("shows");
b1.ToJson("logo");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("ShowId") .HasForeignKey("ShowId")
.HasConstraintName("fk_shows_shows_id"); .HasConstraintName("fk_shows_shows_id");
@ -1187,24 +1214,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 =>
{ {
b1.Property<Guid>("ShowId") b1.Property<Guid>("ShowId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("poster_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("poster_source");
b1.HasKey("ShowId"); b1.HasKey("ShowId");
b1.ToTable("shows"); b1.ToTable("shows");
b1.ToJson("poster");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("ShowId") .HasForeignKey("ShowId")
.HasConstraintName("fk_shows_shows_id"); .HasConstraintName("fk_shows_shows_id");
@ -1213,24 +1242,26 @@ namespace Kyoo.Postgresql.Migrations
b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 =>
{ {
b1.Property<Guid>("ShowId") b1.Property<Guid>("ShowId")
.HasColumnType("uuid") .HasColumnType("uuid");
.HasColumnName("id");
b1.Property<string>("Blurhash") b1.Property<string>("Blurhash")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)");
.HasColumnName("thumbnail_blurhash");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<string>("Source") b1.Property<string>("Source")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text");
.HasColumnName("thumbnail_source");
b1.HasKey("ShowId"); b1.HasKey("ShowId");
b1.ToTable("shows"); b1.ToTable("shows");
b1.ToJson("thumbnail");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("ShowId") .HasForeignKey("ShowId")
.HasConstraintName("fk_shows_shows_id"); .HasConstraintName("fk_shows_shows_id");

View File

@ -94,6 +94,7 @@ public class PostgresContext(DbContextOptions options, IHttpContextAccessor acce
typeof(Dictionary<string, ExternalToken>), typeof(Dictionary<string, ExternalToken>),
new JsonTypeHandler<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<string>), new ListTypeHandler<string>());
SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>()); SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>());
SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); 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"), UserName = builder.Configuration.GetValue("RABBITMQ_DEFAULT_USER", "guest"),
Password = builder.Configuration.GetValue("RABBITMQ_DEFAULT_PASS", "guest"), Password = builder.Configuration.GetValue("RABBITMQ_DEFAULT_PASS", "guest"),
HostName = builder.Configuration.GetValue("RABBITMQ_HOST", "rabbitmq"), HostName = builder.Configuration.GetValue("RABBITMQ_HOST", "rabbitmq"),
Port = 5672, Port = builder.Configuration.GetValue("RABBITMQ_Port", 5672),
}; };
return factory.CreateConnection(); return factory.CreateConnection();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@
*/ */
import { Platform } from "react-native"; import { Platform } from "react-native";
import { ZodObject, ZodRawShape, z } from "zod"; import { z } from "zod";
import { lastUsedUrl } from ".."; import { lastUsedUrl } from "..";
export const imageFn = (url: string) => export const imageFn = (url: string) =>
@ -28,9 +28,12 @@ export const imageFn = (url: string) =>
export const Img = z.object({ export const Img = z.object({
source: z.string(), source: z.string(),
blurhash: 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 * 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 * 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(), 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. * 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 "??:??"; return "??:??";
const h = Math.floor(timer / 3600); const h = Math.floor(timer / 3600);
const min = Math.floor((timer / 60) % 60); 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"); const fmt = (n: number) => n.toString().padStart(2, "0");
if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`; 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: async with KyooClient() as kyoo, Subscriber() as sub:
provider = Provider.get_default(kyoo.client) provider = Provider.get_default(kyoo.client)
scanner = Matcher(kyoo, provider) matcher = Matcher(kyoo, provider)
await sub.listen(scanner) await sub.listen(matcher)

View File

@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
from typing import Literal
import asyncio import asyncio
from logging import getLogger from logging import getLogger
from providers.provider import Provider, ProviderError from providers.provider import Provider, ProviderError
@ -165,3 +166,32 @@ class Matcher:
return await self._client.post("seasons", data=season.to_kyoo()) return await self._client.post("seasons", data=season.to_kyoo())
return await create_season(show_id, season_number) 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 import asyncio
from dataclasses import dataclass from typing import Union, Literal
from dataclasses_json import DataClassJsonMixin from msgspec import Struct, json
from typing import Literal
import os import os
import logging import logging
from aio_pika import connect_robust from aio_pika import connect_robust
@ -12,18 +11,33 @@ from matcher.matcher import Matcher
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass class Message(Struct, tag_field="action", tag=str.lower):
class Message(DataClassJsonMixin): pass
action: Literal["scan", "delete"]
class Scan(Message):
path: str 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: class Subscriber:
QUEUE = "scanner" QUEUE = "scanner"
async def __aenter__(self): async def __aenter__(self):
self._con = await connect_robust( self._con = await connect_robust(
host=os.environ.get("RABBITMQ_HOST", "rabbitmq"), host=os.environ.get("RABBITMQ_HOST", "rabbitmq"),
port=int(os.environ.get("RABBITMQ_PORT", "5672")),
login=os.environ.get("RABBITMQ_DEFAULT_USER", "guest"), login=os.environ.get("RABBITMQ_DEFAULT_USER", "guest"),
password=os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"), password=os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"),
) )
@ -36,18 +50,24 @@ class Subscriber:
async def listen(self, scanner: Matcher): async def listen(self, scanner: Matcher):
async def on_message(message: AbstractIncomingMessage): async def on_message(message: AbstractIncomingMessage):
msg = Message.from_json(message.body) try:
ack = False msg = decoder.decode(message.body)
match msg.action: ack = False
case "scan": match msg:
ack = await scanner.identify(msg.path) case Scan(path):
case "delete": ack = await scanner.identify(path)
ack = await scanner.delete(msg.path) case Delete(path):
case _: ack = await scanner.delete(path)
logger.error(f"Invalid action: {msg.action}") case Refresh(kind, id):
if ack: ack = await scanner.refresh(kind, id)
await message.ack() case _:
else: 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() await message.reject()
# Allow up to 20 scan requests to run in parallel on the same listener. # 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() await self.client.close()
async def get_registered_paths(self) -> List[str]: async def get_registered_paths(self) -> List[str]:
paths = None
async with self.client.get( async with self.client.get(
f"{self._url}/episodes", f"{self._url}/paths",
params={"limit": 0},
headers={"X-API-Key": self._api_key}, headers={"X-API-Key": self._api_key},
) as r: ) as r:
r.raise_for_status() r.raise_for_status()
ret = await r.json() return 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
async def create_issue(self, path: str, issue: str, extra: dict | None = None): async def create_issue(self, path: str, issue: str, extra: dict | None = None):
async with self.client.post( async with self.client.post(
@ -112,20 +99,6 @@ class KyooClient:
logger.error(f"Request error: {await r.text()}") logger.error(f"Request error: {await r.text()}")
r.raise_for_status() r.raise_for_status()
ret = await r.json() 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"] return ret["id"]
async def delete( async def delete(
@ -154,3 +127,35 @@ class KyooClient:
r.raise_for_status() r.raise_for_status()
await self.delete_issue(path) 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 jsons
watchfiles watchfiles
aio-pika aio-pika
dataclasses-json msgspec

View File

@ -7,7 +7,7 @@ logger = getLogger(__name__)
async def monitor(path: str, publisher: Publisher): 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: for event, file in changes:
if event == Change.added: if event == Change.added:
await publisher.add(file) await publisher.add(file)

View File

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

View File

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

View File

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