Merge pull request #31 from AnonymusRaccoon/tvdb

Adding a tvdb provider
This commit is contained in:
Zoe Roux 2021-07-25 15:54:09 +02:00 committed by GitHub
commit 5fd1917ab4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
118 changed files with 4256 additions and 1396 deletions

View File

@ -9,6 +9,7 @@ using IdentityServer4.Services;
using Kyoo.Authentication.Models;
using Kyoo.Authentication.Views;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
using Kyoo.Models.Permissions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@ -64,6 +65,11 @@ namespace Kyoo.Authentication
/// The environment information to check if the app runs in debug mode
/// </summary>
private readonly IWebHostEnvironment _environment;
/// <summary>
/// The configuration manager used to register typed/untyped implementations.
/// </summary>
[Injected] public IConfigurationManager ConfigurationManager { private get; set; }
/// <summary>
@ -98,9 +104,7 @@ namespace Kyoo.Authentication
services.Configure<PermissionOption>(_configuration.GetSection(PermissionOption.Path));
services.Configure<CertificateOption>(_configuration.GetSection(CertificateOption.Path));
services.Configure<AuthenticationOption>(_configuration.GetSection(AuthenticationOption.Path));
services.AddConfiguration<AuthenticationOption>(AuthenticationOption.Path);
List<Client> clients = new();
_configuration.GetSection("authentication:clients").Bind(clients);
CertificateOption certificateOptions = new();
@ -139,6 +143,8 @@ namespace Kyoo.Authentication
/// <inheritdoc />
public void ConfigureAspNet(IApplicationBuilder app)
{
ConfigurationManager.AddTyped<AuthenticationOption>(AuthenticationOption.Path);
app.UseCookiePolicy(new CookiePolicyOptions
{
MinimumSameSitePolicy = SameSiteMode.Strict

View File

@ -36,7 +36,7 @@ namespace Kyoo.Authentication.Views
/// <summary>
/// A file manager to send profile pictures
/// </summary>
private readonly IFileManager _files;
private readonly IFileSystem _files;
/// <summary>
/// Options about authentication. Those options are monitored and reloads are supported.
/// </summary>
@ -50,7 +50,7 @@ namespace Kyoo.Authentication.Views
/// <param name="files">A file manager to send profile pictures</param>
/// <param name="options">Authentication options (this may be hot reloaded)</param>
public AccountApi(IUserRepository users,
IFileManager files,
IFileSystem files,
IOptions<AuthenticationOption> options)
{
_users = users;
@ -205,8 +205,8 @@ namespace Kyoo.Authentication.Views
user.Username = data.Username;
if (data.Picture?.Length > 0)
{
string path = Path.Combine(_options.Value.ProfilePicturePath, user.ID.ToString());
await using Stream file = _files.NewFile(path);
string path = _files.Combine(_options.Value.ProfilePicturePath, user.ID.ToString());
await using Stream file = await _files.NewFile(path);
await data.Picture.CopyToAsync(file);
}
return await _users.Edit(user, false);

View File

@ -12,6 +12,21 @@ namespace Kyoo.Controllers
/// </summary>
public interface IConfigurationManager
{
/// <summary>
/// Add an editable configuration to the editable configuration list
/// </summary>
/// <param name="path">The root path of the editable configuration. It should not be a nested type.</param>
/// <typeparam name="T">The type of the configuration</typeparam>
void AddTyped<T>(string path);
/// <summary>
/// Add an editable configuration to the editable configuration list.
/// WARNING: this method allow you to add an unmanaged type. This type won't be editable. This can be used
/// for external libraries or variable arguments.
/// </summary>
/// <param name="path">The root path of the editable configuration. It should not be a nested type.</param>
void AddUntyped(string path);
/// <summary>
/// Get the value of a setting using it's path.
/// </summary>

View File

@ -10,7 +10,7 @@ namespace Kyoo.Controllers
/// <summary>
/// A service to abstract the file system to allow custom file systems (like distant file systems or external providers)
/// </summary>
public interface IFileManager
public interface IFileSystem
{
// TODO find a way to handle Transmux/Transcode with this system.
@ -41,21 +41,37 @@ namespace Kyoo.Controllers
/// <param name="path">The path of the file</param>
/// <exception cref="FileNotFoundException">If the file could not be found.</exception>
/// <returns>A reader to read the file.</returns>
public Stream GetReader([NotNull] string path);
public Task<Stream> GetReader([NotNull] string path);
/// <summary>
/// Create a new file at <paramref name="path"></paramref>.
/// </summary>
/// <param name="path">The path of the new file.</param>
/// <returns>A writer to write to the new file.</returns>
public Stream NewFile([NotNull] string path);
public Task<Stream> NewFile([NotNull] string path);
/// <summary>
/// Create a new directory at the given path
/// </summary>
/// <param name="path">The path of the directory</param>
/// <returns>The path of the newly created directory is returned.</returns>
public Task<string> CreateDirectory([NotNull] string path);
/// <summary>
/// Combine multiple paths.
/// </summary>
/// <param name="paths">The paths to combine</param>
/// <returns>The combined path.</returns>
public string Combine(params string[] paths);
/// <summary>
/// List files in a directory.
/// </summary>
/// <param name="path">The path of the directory</param>
/// <param name="options">Should the search be recursive or not.</param>
/// <returns>A list of files's path.</returns>
public Task<ICollection<string>> ListFiles([NotNull] string path);
public Task<ICollection<string>> ListFiles([NotNull] string path,
SearchOption options = SearchOption.TopDirectoryOnly);
/// <summary>
/// Check if a file exists at the given path.
@ -71,24 +87,6 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="show">The show to proceed</param>
/// <returns>The extra directory of the show</returns>
public string GetExtraDirectory(Show show);
/// <summary>
/// Get the extra directory of a season.
/// This method is in this system to allow a filesystem to use a different metadata policy for one.
/// It can be useful if the filesystem is readonly.
/// </summary>
/// <param name="season">The season to proceed</param>
/// <returns>The extra directory of the season</returns>
public string GetExtraDirectory(Season season);
/// <summary>
/// Get the extra directory of an episode.
/// This method is in this system to allow a filesystem to use a different metadata policy for one.
/// It can be useful if the filesystem is readonly.
/// </summary>
/// <param name="episode">The episode to proceed</param>
/// <returns>The extra directory of the episode</returns>
public string GetExtraDirectory(Episode episode);
public string GetExtraDirectory([NotNull] Show show);
}
}

View File

@ -0,0 +1,37 @@
using System.Threading.Tasks;
using Kyoo.Models;
using Kyoo.Models.Exceptions;
namespace Kyoo.Controllers
{
/// <summary>
/// An interface to identify episodes, shows and metadata based on the episode file.
/// </summary>
public interface IIdentifier
{
/// <summary>
/// Identify a path and return the parsed metadata.
/// </summary>
/// <param name="path">The path of the episode file to parse.</param>
/// <exception cref="IdentificationFailedException">
/// The identifier could not work for the given path.
/// </exception>
/// <returns>
/// A tuple of models representing parsed metadata.
/// If no metadata could be parsed for a type, null can be returned.
/// </returns>
Task<(Collection, Show, Season, Episode)> Identify(string path);
/// <summary>
/// Identify an external subtitle or track file from it's path and return the parsed metadata.
/// </summary>
/// <param name="path">The path of the external track file to parse.</param>
/// <exception cref="IdentificationFailedException">
/// The identifier could not work for the given path.
/// </exception>
/// <returns>
/// The metadata of the track identified.
/// </returns>
Task<Track> IdentifyTrack(string path);
}
}

View File

@ -1,21 +1,76 @@
using Kyoo.Models;
using System.Collections.Generic;
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace Kyoo.Controllers
{
/// <summary>
/// An interface to automatically retrieve metadata from external providers.
/// </summary>
public interface IMetadataProvider
{
/// <summary>
/// The <see cref="Provider"/> corresponding to this provider.
/// This allow to map metadata to a provider, keep metadata links and
/// know witch <see cref="IMetadataProvider"/> is used for a specific <see cref="Library"/>.
/// </summary>
Provider Provider { get; }
Task<Collection> GetCollectionFromName(string name);
/// <summary>
/// Return a new item with metadata from your provider.
/// </summary>
/// <param name="item">
/// The item to retrieve metadata from. Most of the time, only the name will be available but other
/// properties may be filed by other providers before a call to this method. This can allow you to identify
/// the collection on your provider.
/// </param>
/// <remarks>
/// You must not use metadata from the given <paramref name="item"/>.
/// Merging metadata is the job of Kyoo, a complex <typeparamref name="T"/> is given
/// to make a precise search and give you every available properties, not to discard properties.
/// </remarks>
/// <returns>A new <typeparamref name="T"/> containing metadata from your provider or null</returns>
[ItemCanBeNull]
Task<T> Get<T>([NotNull] T item)
where T : class, IResource;
Task<Show> GetShowByID(Show show);
Task<ICollection<Show>> SearchShows(string showName, bool isMovie);
Task<ICollection<PeopleRole>> GetPeople(Show show);
/// <summary>
/// Search for a specific type of items with a given query.
/// </summary>
/// <param name="query">The search query to use.</param>
/// <returns>The list of items that could be found on this specific provider.</returns>
[ItemNotNull]
Task<ICollection<T>> Search<T>(string query)
where T : class, IResource;
}
Task<Season> GetSeason(Show show, int seasonNumber);
/// <summary>
/// A special <see cref="IMetadataProvider"/> that merge results.
/// This interface exists to specify witch provider to use but it can be used like any other metadata provider.
/// </summary>
public abstract class AProviderComposite : IMetadataProvider
{
/// <inheritdoc />
[ItemNotNull]
public abstract Task<T> Get<T>(T item)
where T : class, IResource;
Task<Episode> GetEpisode(Show show, int? seasonNumber, int? episodeNumber, int? absoluteNumber);
/// <inheritdoc />
public abstract Task<ICollection<T>> Search<T>(string query)
where T : class, IResource;
/// <summary>
/// Since this is a composite and not a real provider, no metadata is available.
/// It is not meant to be stored or selected. This class will handle merge based on what is required.
/// </summary>
public Provider Provider => null;
/// <summary>
/// Select witch providers to use.
/// The <see cref="IMetadataProvider"/> associated with the given <see cref="Provider"/> will be used.
/// </summary>
/// <param name="providers">The list of providers to use</param>
public abstract void UseProviders(IEnumerable<Provider> providers);
}
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Autofac;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
@ -10,8 +11,10 @@ namespace Kyoo.Controllers
/// <summary>
/// A common interface used to discord plugins
/// </summary>
/// <remarks>You can inject services in the IPlugin constructor.
/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment.</remarks>
/// <remarks>
/// You can inject services in the IPlugin constructor.
/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment.
/// </remarks>
[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
public interface IPlugin
{
@ -55,6 +58,15 @@ namespace Kyoo.Controllers
/// </remarks>
ICollection<Type> Requires { get; }
/// <summary>
/// A configure method that will be run on plugin's startup.
/// </summary>
/// <param name="builder">The autofac service container to register services.</param>
void Configure(ContainerBuilder builder)
{
// Skipped
}
/// <summary>
/// A configure method that will be run on plugin's startup.
/// </summary>
@ -64,21 +76,34 @@ namespace Kyoo.Controllers
/// or <see cref="ProviderCondition.Has(System.Collections.Generic.ICollection{System.Type},System.Collections.Generic.ICollection{System.Type})"/>>
/// You can't simply check on the service collection because some dependencies might be registered after your plugin.
/// </param>
void Configure(IServiceCollection services, ICollection<Type> availableTypes);
void Configure(IServiceCollection services, ICollection<Type> availableTypes)
{
// Skipped
}
/// <summary>
/// An optional configuration step to allow a plugin to change asp net configurations.
/// WARNING: This is only called on Kyoo's startup so you must restart the app to apply this changes.
/// </summary>
/// <param name="app">The Asp.Net application builder. On most case it is not needed but you can use it to add asp net functionalities.</param>
void ConfigureAspNet(IApplicationBuilder app) {}
/// <param name="app">
/// The Asp.Net application builder. On most case it is not needed but you can use it to
/// add asp net functionalities.
/// </param>
void ConfigureAspNet(IApplicationBuilder app)
{
// Skipped
}
/// <summary>
/// An optional function to execute and initialize your plugin.
/// It can be used to initialize a database connection, fill initial data or anything.
/// </summary>
/// <param name="provider">A service provider to request services</param>
void Initialize(IServiceProvider provider) {}
void Initialize(IServiceProvider provider)
{
// Skipped
}
}
/// <summary>

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using Autofac;
using Kyoo.Models.Exceptions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
@ -42,7 +43,13 @@ namespace Kyoo.Controllers
public void LoadPlugins(ICollection<IPlugin> plugins);
/// <summary>
/// Configure services adding or removing services as the plugins wants.
/// Configure container adding or removing services as the plugins wants.
/// </summary>
/// <param name="builder">The container to populate</param>
void ConfigureContainer(ContainerBuilder builder);
/// <summary>
/// Configure services via the microsoft way. This allow libraries to add their services.
/// </summary>
/// <param name="services">The service collection to populate</param>
public void ConfigureServices(IServiceCollection services);

View File

@ -1,17 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Kyoo.Models;
namespace Kyoo.Controllers
{
public interface IProviderManager
{
Task<Collection> GetCollectionFromName(string name, Library library);
Task<Show> CompleteShow(Show show, Library library);
Task<Show> SearchShow(string showName, bool isMovie, Library library);
Task<IEnumerable<Show>> SearchShows(string showName, bool isMovie, Library library);
Task<Season> GetSeason(Show show, int seasonNumber, Library library);
Task<Episode> GetEpisode(Show show, string episodePath, int? seasonNumber, int? episodeNumber, int? absoluteNumber, Library library);
Task<ICollection<PeopleRole>> GetPeople(Show show, Library library);
}
}

View File

@ -128,6 +128,7 @@ namespace Kyoo.Controllers
/// <param name="id">The id of the resource</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
[ItemNotNull]
Task<T> Get(int id);
/// <summary>
/// Get a resource from it's slug.
@ -135,6 +136,7 @@ namespace Kyoo.Controllers
/// <param name="slug">The slug of the resource</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
[ItemNotNull]
Task<T> Get(string slug);
/// <summary>
/// Get the first resource that match the predicate.
@ -142,6 +144,7 @@ namespace Kyoo.Controllers
/// <param name="where">A predicate to filter the resource.</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
[ItemNotNull]
Task<T> Get(Expression<Func<T, bool>> where);
/// <summary>
@ -149,18 +152,21 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="id">The id of the resource</param>
/// <returns>The resource found</returns>
[ItemCanBeNull]
Task<T> GetOrDefault(int id);
/// <summary>
/// Get a resource from it's slug or null if it is not found.
/// </summary>
/// <param name="slug">The slug of the resource</param>
/// <returns>The resource found</returns>
[ItemCanBeNull]
Task<T> GetOrDefault(string slug);
/// <summary>
/// Get the first resource that match the predicate or null if it is not found.
/// </summary>
/// <param name="where">A predicate to filter the resource.</param>
/// <returns>The resource found</returns>
[ItemCanBeNull]
Task<T> GetOrDefault(Expression<Func<T, bool>> where);
/// <summary>
@ -168,6 +174,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="query">The query string.</param>
/// <returns>A list of resources found</returns>
[ItemNotNull]
Task<ICollection<T>> Search(string query);
/// <summary>
@ -177,6 +184,7 @@ namespace Kyoo.Controllers
/// <param name="sort">Sort information about the query (sort by, sort order)</param>
/// <param name="limit">How pagination should be done (where to start and how many to return)</param>
/// <returns>A list of resources that match every filters</returns>
[ItemNotNull]
Task<ICollection<T>> GetAll(Expression<Func<T, bool>> where = null,
Sort<T> sort = default,
Pagination limit = default);
@ -187,6 +195,7 @@ namespace Kyoo.Controllers
/// <param name="sort">A sort by predicate. The order is ascending.</param>
/// <param name="limit">How pagination should be done (where to start and how many to return)</param>
/// <returns>A list of resources that match every filters</returns>
[ItemNotNull]
Task<ICollection<T>> GetAll([Optional] Expression<Func<T, bool>> where,
Expression<Func<T, object>> sort,
Pagination limit = default
@ -205,6 +214,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="obj">The item to register</param>
/// <returns>The resource registers and completed by database's information (related items & so on)</returns>
[ItemNotNull]
Task<T> Create([NotNull] T obj);
/// <summary>
@ -212,6 +222,7 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="obj">The object to create</param>
/// <returns>The newly created item or the existing value if it existed.</returns>
[ItemNotNull]
Task<T> CreateIfNotExists([NotNull] T obj);
/// <summary>
@ -221,6 +232,7 @@ namespace Kyoo.Controllers
/// <param name="resetOld">Should old properties of the resource be discarded or should null values considered as not changed?</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The resource edited and completed by database's information (related items & so on)</returns>
[ItemNotNull]
Task<T> Edit([NotNull] T edited, bool resetOld);
/// <summary>

View File

@ -3,12 +3,15 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Models;
using Kyoo.Models.Attributes;
using Kyoo.Models.Exceptions;
namespace Kyoo.Controllers
{
/// <summary>
/// A single task parameter. This struct contains metadata to display and utility functions to get them in the taks.
/// A single task parameter. This struct contains metadata to display and utility functions to get them in the task.
/// </summary>
/// <remarks>This struct will be used to generate the swagger documentation of the task.</remarks>
public record TaskParameter
@ -60,6 +63,24 @@ namespace Kyoo.Controllers
};
}
/// <summary>
/// Create a new required task parameter.
/// </summary>
/// <param name="name">The name of the parameter</param>
/// <param name="description">The description of the parameter</param>
/// <typeparam name="T">The type of the parameter.</typeparam>
/// <returns>A new task parameter.</returns>
public static TaskParameter CreateRequired<T>(string name, string description)
{
return new()
{
Name = name,
Description = description,
Type = typeof(T),
IsRequired = true
};
}
/// <summary>
/// Create a parameter's value to give to a task.
/// </summary>
@ -94,6 +115,17 @@ namespace Kyoo.Controllers
/// <returns>The value of this parameter.</returns>
public T As<T>()
{
if (typeof(T) == typeof(object))
return (T)Value;
if (Value is IResource resource)
{
if (typeof(T) == typeof(string))
return (T)(object)resource.Slug;
if (typeof(T) == typeof(int))
return (T)(object)resource.ID;
}
return (T)Convert.ChangeType(Value, typeof(T));
}
}
@ -131,58 +163,38 @@ namespace Kyoo.Controllers
public interface ITask
{
/// <summary>
/// The slug of the task, used to start it.
/// The list of parameters
/// </summary>
public string Slug { get; }
/// <summary>
/// The name of the task that will be displayed to the user.
/// </summary>
public string Name { get; }
/// <summary>
/// A quick description of what this task will do.
/// </summary>
public string Description { get; }
/// <summary>
/// An optional message to display to help the user.
/// </summary>
public string HelpMessage { get; }
/// <summary>
/// Should this task be automatically run at app startup?
/// </summary>
public bool RunOnStartup { get; }
/// <summary>
/// The priority of this task. Only used if <see cref="RunOnStartup"/> is true.
/// It allow one to specify witch task will be started first as tasked are run on a Priority's descending order.
/// </summary>
public int Priority { get; }
/// <returns>
/// All parameters that this task as. Every one of them will be given to the run function with a value.
/// </returns>
public TaskParameters GetParameters();
/// <summary>
/// Start this task.
/// </summary>
/// <param name="arguments">The list of parameters.</param>
/// <param name="arguments">
/// The list of parameters.
/// </param>
/// <param name="progress">
/// The progress reporter. Used to inform the sender the percentage of completion of this task
/// .</param>
/// <param name="cancellationToken">A token to request the task's cancellation.
/// If this task is not cancelled quickly, it might be killed by the runner.</param>
/// If this task is not cancelled quickly, it might be killed by the runner.
/// </param>
/// <remarks>
/// Your task can have any service as a public field and use the <see cref="InjectedAttribute"/>,
/// they will be set to an available service from the service container before calling this method.
/// They also will be removed after this method return (or throw) to prevent dangling services.
/// </remarks>
public Task Run(TaskParameters arguments, CancellationToken cancellationToken);
/// <summary>
/// The list of parameters
/// </summary>
/// <returns>All parameters that this task as. Every one of them will be given to the run function with a value.</returns>
public TaskParameters GetParameters();
/// <summary>
/// If this task is running, return the percentage of completion of this task or null if no percentage can be given.
/// </summary>
/// <returns>The percentage of completion of the task.</returns>
public int? Progress();
/// <exception cref="TaskFailedException">
/// An exception meaning that the task has failed for handled reasons like invalid arguments,
/// invalid environment, missing plugins or failures not related to a default in the code.
/// This exception allow the task to display a failure message to the end user while others exceptions
/// will be displayed as unhandled exceptions and display a stack trace.
/// </exception>
public Task Run([NotNull] TaskParameters arguments,
[NotNull] IProgress<float> progress,
CancellationToken cancellationToken);
}
}

View File

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models.Exceptions;
namespace Kyoo.Controllers
@ -13,22 +16,67 @@ namespace Kyoo.Controllers
/// <summary>
/// Start a new task (or queue it).
/// </summary>
/// <param name="taskSlug">The slug of the task to run</param>
/// <param name="arguments">A list of arguments to pass to the task. An automatic conversion will be made if arguments to not fit.</param>
/// <exception cref="ArgumentException">If the number of arguments is invalid or if an argument can't be converted.</exception>
/// <exception cref="ItemNotFoundException">The task could not be found.</exception>
void StartTask(string taskSlug, Dictionary<string, object> arguments = null);
/// <param name="taskSlug">
/// The slug of the task to run.
/// </param>
/// <param name="progress">
/// A progress reporter to know the percentage of completion of the task.
/// </param>
/// <param name="arguments">
/// A list of arguments to pass to the task. An automatic conversion will be made if arguments to not fit.
/// </param>
/// <param name="cancellationToken">
/// A custom cancellation token for the task.
/// </param>
/// <exception cref="ArgumentException">
/// If the number of arguments is invalid, if an argument can't be converted or if the task finds the argument
/// invalid.
/// </exception>
/// <exception cref="ItemNotFoundException">
/// The task could not be found.
/// </exception>
void StartTask(string taskSlug,
[NotNull] IProgress<float> progress,
Dictionary<string, object> arguments = null,
CancellationToken? cancellationToken = null);
/// <summary>
/// Start a new task (or queue it).
/// </summary>
/// <param name="progress">
/// A progress reporter to know the percentage of completion of the task.
/// </param>
/// <param name="arguments">
/// A list of arguments to pass to the task. An automatic conversion will be made if arguments to not fit.
/// </param>
/// <typeparam name="T">
/// The type of the task to start.
/// </typeparam>
/// <param name="cancellationToken">
/// A custom cancellation token for the task.
/// </param>
/// <exception cref="ArgumentException">
/// If the number of arguments is invalid, if an argument can't be converted or if the task finds the argument
/// invalid.
/// </exception>
/// <exception cref="ItemNotFoundException">
/// The task could not be found.
/// </exception>
void StartTask<T>([NotNull] IProgress<float> progress,
Dictionary<string, object> arguments = null,
CancellationToken? cancellationToken = null)
where T : ITask;
/// <summary>
/// Get all currently running tasks
/// </summary>
/// <returns>A list of currently running tasks.</returns>
ICollection<ITask> GetRunningTasks();
ICollection<(TaskMetadataAttribute, ITask)> GetRunningTasks();
/// <summary>
/// Get all available tasks
/// </summary>
/// <returns>A list of every tasks that this instance know.</returns>
ICollection<ITask> GetAllTasks();
ICollection<TaskMetadataAttribute> GetAllTasks();
}
}

View File

@ -1,23 +1,59 @@
using Kyoo.Models;
using System;
using Kyoo.Models;
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace Kyoo.Controllers
{
/// <summary>
/// Download images and retrieve the path of those images for a resource.
/// </summary>
public interface IThumbnailsManager
{
Task Validate(Show show, bool alwaysDownload = false);
Task Validate(Season season, bool alwaysDownload = false);
Task Validate(Episode episode, bool alwaysDownload = false);
Task Validate(People actors, bool alwaysDownload = false);
Task Validate(Provider actors, bool alwaysDownload = false);
/// <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>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the item</typeparam>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
Task<bool> DownloadImages<T>([NotNull] T item, bool alwaysDownload = false)
where T : IResource;
Task<string> GetShowPoster([NotNull] Show show);
Task<string> GetShowLogo([NotNull] Show show);
Task<string> GetShowBackdrop([NotNull] Show show);
Task<string> GetSeasonPoster([NotNull] Season season);
Task<string> GetEpisodeThumb([NotNull] Episode episode);
Task<string> GetPeoplePoster([NotNull] People people);
Task<string> GetProviderLogo([NotNull] Provider provider);
/// <summary>
/// Retrieve the local path of the poster of the given item.
/// </summary>
/// <param name="item">The item to retrieve the poster from.</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <exception cref="NotSupportedException">If the type does not have a poster</exception>
/// <returns>The path of the poster for the given resource (it might or might not exists).</returns>
Task<string> GetPoster<T>([NotNull] T item)
where T : IResource;
/// <summary>
/// Retrieve the local path of the logo of the given item.
/// </summary>
/// <param name="item">The item to retrieve the logo from.</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <exception cref="NotSupportedException">If the type does not have a logo</exception>
/// <returns>The path of the logo for the given resource (it might or might not exists).</returns>
Task<string> GetLogo<T>([NotNull] T item)
where T : IResource;
/// <summary>
/// Retrieve the local path of the thumbnail of the given item.
/// </summary>
/// <param name="item">The item to retrieve the thumbnail from.</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <exception cref="NotSupportedException">If the type does not have a thumbnail</exception>
/// <returns>The path of the thumbnail for the given resource (it might or might not exists).</returns>
Task<string> GetThumbnail<T>([NotNull] T item)
where T : IResource;
}
}

View File

@ -21,11 +21,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="6.2.0" />
<PackageReference Include="JetBrains.Annotations" Version="2021.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.0-beta-20204-02" PrivateAssets="All" />
<PackageReference Include="System.ComponentModel.Composition" Version="5.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using Kyoo.Controllers;
namespace Kyoo.Common.Models.Attributes
{
/// <summary>
/// An attribute to inform how a <see cref="IFileSystem"/> works.
/// </summary>
[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class)]
public class FileSystemMetadataAttribute : Attribute
{
/// <summary>
/// The scheme(s) used to identify this path.
/// It can be something like http, https, ftp, file and so on.
/// </summary>
/// <remarks>
/// If multiples files with the same schemes exists, an exception will be thrown.
/// </remarks>
public string[] Scheme { get; }
/// <summary>
/// <c>true</c> if the scheme should be removed from the path before calling
/// methods of this <see cref="IFileSystem"/>, <c>false</c> otherwise.
/// </summary>
public bool StripScheme { get; set; }
/// <summary>
/// Create a new <see cref="FileSystemMetadataAttribute"/> using the specified schemes.
/// </summary>
/// <param name="schemes">The schemes to use.</param>
public FileSystemMetadataAttribute(string[] schemes)
{
Scheme = schemes;
}
/// <summary>
/// Create a new <see cref="FileSystemMetadataAttribute"/> using a dictionary of metadata.
/// </summary>
/// <param name="metadata">
/// The dictionary of metadata. This method expect the dictionary to contain a field
/// per property in this attribute, with the same types as the properties of this attribute.
/// </param>
public FileSystemMetadataAttribute(IDictionary<string, object> metadata)
{
Scheme = (string[])metadata[nameof(Scheme)];
StripScheme = (bool)metadata[nameof(StripScheme)];
}
}
}

View File

@ -8,8 +8,8 @@ namespace Kyoo.Models.Attributes
/// An attribute to inform that the service will be injected automatically by a service provider.
/// </summary>
/// <remarks>
/// It should only be used on <see cref="ITask"/> and will be injected before calling <see cref="ITask.Run"/>.
/// It can also be used on <see cref="IPlugin"/> and it will be injected before calling <see cref="IPlugin.ConfigureAspNet"/>.
/// It should only be used on <see cref="IPlugin"/> and it will be injected before
/// calling <see cref="IPlugin.ConfigureAspNet"/>.
/// </remarks>
[AttributeUsage(AttributeTargets.Property)]
[MeansImplicitUse(ImplicitUseKindFlags.Assign)]

View File

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using Kyoo.Controllers;
namespace Kyoo.Common.Models.Attributes
{
/// <summary>
/// An attribute to inform how a <see cref="IFileSystem"/> works.
/// </summary>
[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class)]
public class TaskMetadataAttribute : Attribute
{
/// <summary>
/// The slug of the task, used to start it.
/// </summary>
public string Slug { get; }
/// <summary>
/// The name of the task that will be displayed to the user.
/// </summary>
public string Name { get; }
/// <summary>
/// A quick description of what this task will do.
/// </summary>
public string Description { get; }
/// <summary>
/// Should this task be automatically run at app startup?
/// </summary>
public bool RunOnStartup { get; set; }
/// <summary>
/// The priority of this task. Only used if <see cref="RunOnStartup"/> is true.
/// It allow one to specify witch task will be started first as tasked are run on a Priority's descending order.
/// </summary>
public int Priority { get; set; }
/// <summary>
/// <c>true</c> if this task should not be displayed to the user, <c>false</c> otherwise.
/// </summary>
public bool IsHidden { get; set; }
/// <summary>
/// Create a new <see cref="TaskMetadataAttribute"/> with the given slug, name and description.
/// </summary>
/// <param name="slug">The slug of the task, used to start it.</param>
/// <param name="name">The name of the task that will be displayed to the user.</param>
/// <param name="description">A quick description of what this task will do.</param>
public TaskMetadataAttribute(string slug, string name, string description)
{
Slug = slug;
Name = name;
Description = description;
}
/// <summary>
/// Create a new <see cref="TaskMetadataAttribute"/> using a dictionary of metadata.
/// </summary>
/// <param name="metadata">
/// The dictionary of metadata. This method expect the dictionary to contain a field
/// per property in this attribute, with the same types as the properties of this attribute.
/// </param>
public TaskMetadataAttribute(IDictionary<string, object> metadata)
{
Slug = (string)metadata[nameof(Slug)];
Name = (string)metadata[nameof(Name)];
Description = (string)metadata[nameof(Description)];
RunOnStartup = (bool)metadata[nameof(RunOnStartup)];
Priority = (int)metadata[nameof(Priority)];
IsHidden = (bool)metadata[nameof(IsHidden)];
}
}
}

View File

@ -0,0 +1,37 @@
using System;
using System.Runtime.Serialization;
using Kyoo.Controllers;
namespace Kyoo.Models.Exceptions
{
/// <summary>
/// An exception raised when an <see cref="IIdentifier"/> failed.
/// </summary>
[Serializable]
public class IdentificationFailedException : Exception
{
/// <summary>
/// Create a new <see cref="IdentificationFailedException"/> with a default message.
/// </summary>
public IdentificationFailedException()
: base("An identification failed.")
{}
/// <summary>
/// Create a new <see cref="IdentificationFailedException"/> with a custom message.
/// </summary>
/// <param name="message">The message to use.</param>
public IdentificationFailedException(string message)
: base(message)
{}
/// <summary>
/// The serialization constructor
/// </summary>
/// <param name="info">Serialization infos</param>
/// <param name="context">The serialization context</param>
protected IdentificationFailedException(SerializationInfo info, StreamingContext context)
: base(info, context)
{ }
}
}

View File

@ -0,0 +1,45 @@
using System;
using System.Runtime.Serialization;
using Kyoo.Controllers;
namespace Kyoo.Models.Exceptions
{
/// <summary>
/// An exception raised when an <see cref="ITask"/> failed.
/// </summary>
[Serializable]
public class TaskFailedException : AggregateException
{
/// <summary>
/// Create a new <see cref="TaskFailedException"/> with a default message.
/// </summary>
public TaskFailedException()
: base("A task failed.")
{}
/// <summary>
/// Create a new <see cref="TaskFailedException"/> with a custom message.
/// </summary>
/// <param name="message">The message to use.</param>
public TaskFailedException(string message)
: base(message)
{}
/// <summary>
/// Create a new <see cref="TaskFailedException"/> wrapping another exception.
/// </summary>
/// <param name="exception">The exception to wrap.</param>
public TaskFailedException(Exception exception)
: base(exception)
{}
/// <summary>
/// The serialization constructor
/// </summary>
/// <param name="info">Serialization infos</param>
/// <param name="context">The serialization context</param>
protected TaskFailedException(SerializationInfo info, StreamingContext context)
: base(info, context)
{ }
}
}

View File

@ -58,7 +58,7 @@ namespace Kyoo.Models
/// By default, the http path for this poster is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/{Type}/{Slug}/poster")] public string Poster { get; set; }
[SerializeAs("{HOST}/api/{Type:l}/{Slug}/poster")] public string Poster { get; set; }
/// <summary>
/// The type of this item (ether a collection, a show or a movie).

View File

@ -1,5 +1,6 @@
using System;
using System.Linq.Expressions;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
{
@ -107,12 +108,12 @@ namespace Kyoo.Models
/// <summary>
/// A reference of the first resource.
/// </summary>
public T1 First { get; set; }
[SerializeIgnore] public T1 First { get; set; }
/// <summary>
/// A reference to the second resource.
/// </summary>
public T2 Second { get; set; }
[SerializeIgnore] public T2 Second { get; set; }
/// <summary>

View File

@ -19,6 +19,12 @@ namespace Kyoo.Models
/// The URL of the resource on the external provider.
/// </summary>
public string Link { get; set; }
/// <summary>
/// A shortcut to access the provider of this metadata.
/// Unlike the <see cref="Link{T, T2}.Second"/> property, this is serializable.
/// </summary>
public Provider Provider => Second;
/// <summary>
/// The expression to retrieve the unique ID of a MetadataID. This is an aggregate of the two resources IDs.

View File

@ -20,9 +20,11 @@ namespace Kyoo.Models
{
get
{
if (ShowSlug == null && Show == null)
return GetSlug(ShowID.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber);
return GetSlug(ShowSlug ?? Show.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber);
if (ShowSlug != null || Show != null)
return GetSlug(ShowSlug ?? Show.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber);
return ShowID != 0
? GetSlug(ShowID.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber)
: null;
}
[UsedImplicitly] [NotNull] private set
{
@ -93,7 +95,7 @@ namespace Kyoo.Models
public int? AbsoluteNumber { get; set; }
/// <summary>
/// The path of the video file for this episode. Any format supported by a <see cref="IFileManager"/> is allowed.
/// The path of the video file for this episode. Any format supported by a <see cref="IFileSystem"/> is allowed.
/// </summary>
[SerializeIgnore] public string Path { get; set; }

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using Kyoo.Common.Models.Attributes;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
@ -30,7 +31,7 @@ namespace Kyoo.Models
/// <summary>
/// The path of the root directory of this show.
/// This can be any kind of path supported by <see cref="IFileManager"/>
/// This can be any kind of path supported by <see cref="IFileSystem"/>
/// </summary>
[SerializeIgnore] public string Path { get; set; }
@ -42,10 +43,10 @@ namespace Kyoo.Models
/// <summary>
/// Is this show airing, not aired yet or finished?
/// </summary>
public Status? Status { get; set; }
public Status Status { get; set; }
/// <summary>
/// An URL to a trailer. This could be any path supported by the <see cref="IFileManager"/>.
/// An URL to a trailer. This could be any path supported by the <see cref="IFileSystem"/>.
/// </summary>
/// TODO for now, this is set to a youtube url. It should be cached and converted to a local file.
public string TrailerUrl { get; set; }
@ -157,6 +158,7 @@ namespace Kyoo.Models
/// <remarks>This method will never return anything if the <see cref="ExternalIDs"/> are not loaded.</remarks>
/// <param name="provider">The slug of the provider</param>
/// <returns>The <see cref="MetadataID{T}.DataID"/> field of the asked provider.</returns>
[CanBeNull]
public string GetID(string provider)
{
return ExternalIDs?.FirstOrDefault(x => x.Second.Slug == provider)?.DataID;
@ -183,5 +185,5 @@ namespace Kyoo.Models
/// <summary>
/// The enum containing show's status.
/// </summary>
public enum Status { Finished, Airing, Planned, Unknown }
public enum Status { Unknown, Finished, Airing, Planned }
}

View File

@ -35,8 +35,8 @@ namespace Kyoo.Models
{
string type = Type.ToString().ToLower();
string index = TrackIndex != 0 ? $"-{TrackIndex}" : string.Empty;
string episode = EpisodeSlug ?? Episode.Slug ?? EpisodeID.ToString();
return $"{episode}.{Language}{index}{(IsForced ? ".forced" : "")}.{type}";
string episode = EpisodeSlug ?? Episode?.Slug ?? EpisodeID.ToString();
return $"{episode}.{Language ?? "und"}{index}{(IsForced ? ".forced" : "")}.{type}";
}
[UsedImplicitly] private set
{
@ -47,11 +47,13 @@ namespace Kyoo.Models
if (!match.Success)
throw new ArgumentException("Invalid track slug. " +
"Format: {episodeSlug}.{language}[-{index}][-forced].{type}[.{extension}]");
"Format: {episodeSlug}.{language}[-{index}][.forced].{type}[.{extension}]");
EpisodeSlug = match.Groups["ep"].Value;
Language = match.Groups["lang"].Value;
TrackIndex = int.Parse(match.Groups["index"].Value);
if (Language == "und")
Language = null;
TrackIndex = match.Groups["index"].Success ? int.Parse(match.Groups["index"].Value) : 0;
IsForced = match.Groups["forced"].Success;
Type = Enum.Parse<StreamType>(match.Groups["type"].Value, true);
}
@ -154,30 +156,17 @@ namespace Kyoo.Models
}
/// <summary>
/// Utility method to edit a track slug (this only return a slug with the modification, nothing is stored)
/// Utility method to create a track slug from a incomplete slug (only add the type of the track).
/// </summary>
/// <param name="baseSlug">The slug to edit</param>
/// <param name="type">The new type of this </param>
/// <param name="language"></param>
/// <param name="index"></param>
/// <param name="forced"></param>
/// <returns></returns>
public static string EditSlug(string baseSlug,
StreamType type = StreamType.Unknown,
string language = null,
int? index = null,
bool? forced = null)
public static string BuildSlug(string baseSlug,
StreamType type)
{
Track track = new() {Slug = baseSlug};
if (type != StreamType.Unknown)
track.Type = type;
if (language != null)
track.Language = language;
if (index != null)
track.TrackIndex = index.Value;
if (forced != null)
track.IsForced = forced.Value;
return track.Slug;
return baseSlug.EndsWith($".{type}", StringComparison.InvariantCultureIgnoreCase)
? baseSlug
: $"{baseSlug}.{type.ToString().ToLowerInvariant()}";
}
}
}

View File

@ -62,7 +62,7 @@ namespace Kyoo.Models
public DateTime? ReleaseDate { get; set; }
/// <summary>
/// The path of the video file for this episode. Any format supported by a <see cref="IFileManager"/> is allowed.
/// The path of the video file for this episode. Any format supported by a <see cref="IFileSystem"/> is allowed.
/// </summary>
[SerializeIgnore] public string Path { get; set; }
@ -176,13 +176,16 @@ namespace Kyoo.Models
return new WatchItem
{
EpisodeID = ep.ID,
Slug = ep.Slug,
ShowSlug = ep.Show.Slug,
ShowTitle = ep.Show.Title,
SeasonNumber = ep.SeasonNumber,
EpisodeNumber = ep.EpisodeNumber,
AbsoluteNumber = ep.AbsoluteNumber,
Title = ep.Title,
ReleaseDate = ep.ReleaseDate,
Path = ep.Path,
Container = PathIO.GetExtension(ep.Path)![1..],
Video = ep.Tracks.FirstOrDefault(x => x.Type == StreamType.Video),
Audios = ep.Tracks.Where(x => x.Type == StreamType.Audio).ToArray(),
Subtitles = ep.Tracks.Where(x => x.Type == StreamType.Subtitle).ToArray(),

View File

@ -1,9 +1,7 @@
using System;
using System.Linq;
using Autofac;
using Autofac.Builder;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Kyoo
{
@ -15,86 +13,63 @@ namespace Kyoo
/// <summary>
/// Register a new task to the container.
/// </summary>
/// <param name="services">The container</param>
/// <param name="builder">The container</param>
/// <typeparam name="T">The type of the task</typeparam>
/// <returns>The initial container.</returns>
public static IServiceCollection AddTask<T>(this IServiceCollection services)
/// <returns>The registration builder of this new task. That can be used to edit the registration.</returns>
public static IRegistrationBuilder<T, ConcreteReflectionActivatorData, SingleRegistrationStyle>
RegisterTask<T>(this ContainerBuilder builder)
where T : class, ITask
{
services.AddSingleton<ITask, T>();
return services;
return builder.RegisterType<T>().As<ITask>();
}
/// <summary>
/// Register a new metadata provider to the container.
/// </summary>
/// <param name="builder">The container</param>
/// <typeparam name="T">The type of the task</typeparam>
/// <returns>The registration builder of this new provider. That can be used to edit the registration.</returns>
public static IRegistrationBuilder<T, ConcreteReflectionActivatorData, SingleRegistrationStyle>
RegisterProvider<T>(this ContainerBuilder builder)
where T : class, IMetadataProvider
{
return builder.RegisterType<T>().As<IMetadataProvider>().InstancePerLifetimeScope();
}
/// <summary>
/// Register a new repository to the container.
/// </summary>
/// <param name="services">The container</param>
/// <param name="lifetime">The lifetime of the repository. The default is scoped.</param>
/// <param name="builder">The container</param>
/// <typeparam name="T">The type of the repository.</typeparam>
/// <remarks>
/// If your repository implements a special interface, please use <see cref="AddRepository{T,T2}"/>
/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/>
/// </remarks>
/// <returns>The initial container.</returns>
public static IServiceCollection AddRepository<T>(this IServiceCollection services,
ServiceLifetime lifetime = ServiceLifetime.Scoped)
public static IRegistrationBuilder<T, ConcreteReflectionActivatorData, SingleRegistrationStyle>
RegisterRepository<T>(this ContainerBuilder builder)
where T : IBaseRepository
{
Type repository = Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>));
if (repository != null)
services.Add(ServiceDescriptor.Describe(repository, typeof(T), lifetime));
services.Add(ServiceDescriptor.Describe(typeof(IBaseRepository), typeof(T), lifetime));
return services;
return builder.RegisterType<T>()
.As<IBaseRepository>()
.As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>)))
.InstancePerLifetimeScope();
}
/// <summary>
/// Register a new repository with a custom mapping to the container.
/// </summary>
/// <param name="services"></param>
/// <param name="lifetime">The lifetime of the repository. The default is scoped.</param>
/// <param name="builder">The container</param>
/// <typeparam name="T">The custom mapping you have for your repository.</typeparam>
/// <typeparam name="T2">The type of the repository.</typeparam>
/// <remarks>
/// If your repository does not implements a special interface, please use <see cref="AddRepository{T}"/>
/// If your repository does not implements a special interface, please use <see cref="RegisterRepository{T}"/>
/// </remarks>
/// <returns>The initial container.</returns>
public static IServiceCollection AddRepository<T, T2>(this IServiceCollection services,
ServiceLifetime lifetime = ServiceLifetime.Scoped)
public static IRegistrationBuilder<T2, ConcreteReflectionActivatorData, SingleRegistrationStyle>
RegisterRepository<T, T2>(this ContainerBuilder builder)
where T2 : IBaseRepository, T
{
services.Add(ServiceDescriptor.Describe(typeof(T), typeof(T2), lifetime));
return services.AddRepository<T2>(lifetime);
}
/// <summary>
/// Add an editable configuration to the editable configuration list
/// </summary>
/// <param name="services">The service collection to edit</param>
/// <param name="path">The root path of the editable configuration. It should not be a nested type.</param>
/// <typeparam name="T">The type of the configuration</typeparam>
/// <returns>The given service collection is returned.</returns>
public static IServiceCollection AddConfiguration<T>(this IServiceCollection services, string path)
where T : class
{
if (services.Any(x => x.ServiceType == typeof(T)))
return services;
foreach (ConfigurationReference confRef in ConfigurationReference.CreateReference<T>(path))
services.AddSingleton(confRef);
return services;
}
/// <summary>
/// Add an editable configuration to the editable configuration list.
/// WARNING: this method allow you to add an unmanaged type. This type won't be editable. This can be used
/// for external libraries or variable arguments.
/// </summary>
/// <param name="services">The service collection to edit</param>
/// <param name="path">The root path of the editable configuration. It should not be a nested type.</param>
/// <returns>The given service collection is returned.</returns>
public static IServiceCollection AddUntypedConfiguration(this IServiceCollection services, string path)
{
services.AddSingleton(ConfigurationReference.CreateUntyped(path));
return services;
return builder.RegisterRepository<T2>().As<T>();
}
/// <summary>

View File

@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Linq;
using System.Reflection;
using JetBrains.Annotations;
using Kyoo.Models;
using Kyoo.Models.Attributes;
namespace Kyoo
@ -111,7 +112,8 @@ namespace Kyoo
/// <param name="second">Missing fields of first will be completed by fields of this item. If second is null, the function no-op.</param>
/// <typeparam name="T">Fields of T will be merged</typeparam>
/// <returns><see cref="first"/></returns>
public static T Merge<T>(T first, T second)
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static T Merge<T>([CanBeNull] T first, [CanBeNull] T second)
{
if (first == null)
return second;
@ -127,9 +129,7 @@ namespace Kyoo
{
object oldValue = property.GetValue(first);
object newValue = property.GetValue(second);
object defaultValue = property.PropertyType.IsValueType
? Activator.CreateInstance(property.PropertyType)
: null;
object defaultValue = property.PropertyType.GetClrDefault();
if (oldValue?.Equals(defaultValue) != false)
property.SetValue(first, newValue);
@ -139,11 +139,14 @@ namespace Kyoo
Type enumerableType = Utility.GetGenericDefinition(property.PropertyType, typeof(IEnumerable<>))
.GenericTypeArguments
.First();
Func<IResource, IResource, bool> equalityComparer = enumerableType.IsAssignableTo(typeof(IResource))
? (x, y) => x.Slug == y.Slug
: null;
property.SetValue(first, Utility.RunGenericMethod<object>(
typeof(Utility),
typeof(Merger),
nameof(MergeLists),
enumerableType,
oldValue, newValue, null));
enumerableType,
oldValue, newValue, equalityComparer));
}
}

View File

@ -72,7 +72,7 @@ namespace Kyoo
/// </summary>
/// <param name="str">The string to slugify</param>
/// <returns>The slug version of the given string</returns>
public static string ToSlug(string str)
public static string ToSlug([CanBeNull] string str)
{
if (str == null)
return null;
@ -182,15 +182,49 @@ namespace Kyoo
return types.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
}
public static MethodInfo GetMethod(Type type, BindingFlags flag, string name, Type[] generics, object[] args)
/// <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>
[PublicAPI]
[NotNull]
public static MethodInfo GetMethod([NotNull] Type type,
BindingFlags flag,
string name,
[NotNull] Type[] generics,
[NotNull] object[] args)
{
if (type == null)
throw new ArgumentNullException(nameof(type));
if (generics == null)
throw new ArgumentNullException(nameof(generics));
if (args == null)
throw new ArgumentNullException(nameof(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 NullReferenceException($"A method named {name} with " +
$"{args.Length} arguments and {generics.Length} generic " +
$"types could not be found on {type.Name}."))
.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 =>
// {
@ -211,9 +245,34 @@ namespace Kyoo
if (methods.Length == 1)
return methods[0];
throw new NullReferenceException($"Multiple methods named {name} match the generics and parameters constraints.");
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 <see cref="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}(object,string,System.Type,object[])"/>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/>
public static T RunGenericMethod<T>(
[NotNull] Type owner,
[NotNull] string methodName,
@ -223,6 +282,34 @@ namespace Kyoo
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 <see cref="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}(object,string,System.Type[],object[])"/>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
[PublicAPI]
public static T RunGenericMethod<T>(
[NotNull] Type owner,
[NotNull] string methodName,
@ -238,9 +325,34 @@ namespace Kyoo
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?.ToArray());
return (T)method.MakeGenericMethod(types).Invoke(null, args.ToArray());
}
/// <summary>
/// Run a generic method for a runtime <see cref="Type"/>.
/// </summary>
/// <example>
/// To run <see cref="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="instance">The <c>this</c> of the method to run.</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}(object,string,System.Type,object[])"/>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/>
public static T RunGenericMethod<T>(
[NotNull] object instance,
[NotNull] string methodName,
@ -250,6 +362,33 @@ namespace Kyoo
return RunGenericMethod<T>(instance, methodName, new[] {type}, args);
}
/// <summary>
/// Run a generic method for a multiple runtime <see cref="Type"/>.
/// If your generic method only needs one type, see
/// <see cref="RunGenericMethod{T}(object,string,System.Type,object[])"/>
/// </summary>
/// <example>
/// To run <see cref="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="instance">The <c>this</c> of the method to run.</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}(object,string,System.Type[],object[])"/>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
public static T RunGenericMethod<T>(
[NotNull] object instance,
[NotNull] string methodName,
@ -263,7 +402,7 @@ namespace Kyoo
if (types == null || types.Length == 0)
throw new ArgumentNullException(nameof(types));
MethodInfo method = GetMethod(instance.GetType(), BindingFlags.Instance, methodName, types, args);
return (T)method.MakeGenericMethod(types).Invoke(instance, args?.ToArray());
return (T)method.MakeGenericMethod(types).Invoke(instance, args.ToArray());
}
public static string ToQueryString(this Dictionary<string, string> query)

View File

@ -38,7 +38,7 @@ namespace Kyoo.CommonApi
[PartialPermission(Kind.Read)]
public virtual async Task<ActionResult<T>> Get(string slug)
{
T ret = await _repository.Get(slug);
T ret = await _repository.GetOrDefault(slug);
if (ret == null)
return NotFound();
return ret;

View File

@ -115,9 +115,10 @@ namespace Kyoo.Controllers
public object GetValue(object target)
{
return Regex.Replace(_format, @"(?<!{){(\w+)}", x =>
return Regex.Replace(_format, @"(?<!{){(\w+)(:(\w+))?}", x =>
{
string value = x.Groups[1].Value;
string modifier = x.Groups[3].Value;
if (value == "HOST")
return _host;
@ -127,9 +128,22 @@ namespace Kyoo.Controllers
.FirstOrDefault(y => y.Name == value);
if (properties == null)
return null;
if (properties.GetValue(target) is string ret)
return ret;
throw new ArgumentException($"Invalid serializer replacement {value}");
object objValue = properties.GetValue(target);
if (objValue is not string ret)
ret = objValue?.ToString();
if (ret == null)
throw new ArgumentException($"Invalid serializer replacement {value}");
foreach (char modification in modifier)
{
ret = modification switch
{
'l' => ret.ToLowerInvariant(),
'u' => ret.ToUpperInvariant(),
_ => throw new ArgumentException($"Invalid serializer modificator {modification}.")
};
}
return ret;
});
}

View File

@ -208,7 +208,7 @@ namespace Kyoo.Controllers
}
catch (DuplicatedItemException)
{
return await GetOrDefault(obj.Slug);
return await Get(obj.Slug);
}
}

View File

@ -43,22 +43,31 @@ namespace Kyoo.CommonApi
PropertyInfo[] properties = type.GetProperties()
.Where(x => x.GetCustomAttribute<LoadableRelationAttribute>() != null)
.ToArray();
fields = fields.Select(x =>
{
string property = properties
.FirstOrDefault(y => string.Equals(x, y.Name, StringComparison.InvariantCultureIgnoreCase))
?.Name;
if (property != null)
return property;
context.Result = new BadRequestObjectResult(new
if (fields.Count == 1 && fields.Contains("all"))
{
fields = properties.Select(x => x.Name).ToList();
}
else
{
fields = fields
.Select(x =>
{
Error = $"{x} does not exist on {type.Name}."
});
return null;
})
.ToList();
if (context.Result != null)
return;
string property = properties
.FirstOrDefault(y
=> string.Equals(x, y.Name, StringComparison.InvariantCultureIgnoreCase))
?.Name;
if (property != null)
return property;
context.Result = new BadRequestObjectResult(new
{
Error = $"{x} does not exist on {type.Name}."
});
return null;
})
.ToList();
if (context.Result != null)
return;
}
}
context.HttpContext.Items["fields"] = fields;
base.OnActionExecuting(context);

View File

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Kyoo.Postgresql.Migrations
{
[DbContext(typeof(PostgresContext))]
[Migration("20210627141933_Initial")]
[Migration("20210723224326_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -20,10 +20,10 @@ namespace Kyoo.Postgresql.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasPostgresEnum(null, "item_type", new[] { "show", "movie", "collection" })
.HasPostgresEnum(null, "status", new[] { "finished", "airing", "planned", "unknown" })
.HasPostgresEnum(null, "status", new[] { "unknown", "finished", "airing", "planned" })
.HasPostgresEnum(null, "stream_type", new[] { "unknown", "video", "audio", "subtitle", "attachment" })
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.7")
.HasAnnotation("ProductVersion", "5.0.8")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("Kyoo.Models.Collection", b =>
@ -189,6 +189,51 @@ namespace Kyoo.Postgresql.Migrations
b.ToTable("libraries");
});
modelBuilder.Entity("Kyoo.Models.LibraryItem", b =>
{
b.Property<int>("ID")
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTime?>("EndAir")
.HasColumnType("timestamp without time zone")
.HasColumnName("end_air");
b.Property<string>("Overview")
.HasColumnType("text")
.HasColumnName("overview");
b.Property<string>("Poster")
.HasColumnType("text")
.HasColumnName("poster");
b.Property<string>("Slug")
.HasColumnType("text")
.HasColumnName("slug");
b.Property<DateTime?>("StartAir")
.HasColumnType("timestamp without time zone")
.HasColumnName("start_air");
b.Property<Status?>("Status")
.HasColumnType("status")
.HasColumnName("status");
b.Property<string>("Title")
.HasColumnType("text")
.HasColumnName("title");
b.Property<ItemType>("Type")
.HasColumnType("item_type")
.HasColumnName("type");
b.HasKey("ID")
.HasName("pk_library_items");
b.ToView("library_items");
});
modelBuilder.Entity("Kyoo.Models.Link<Kyoo.Models.Collection, Kyoo.Models.Show>", b =>
{
b.Property<int>("FirstID")
@ -621,7 +666,7 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("timestamp without time zone")
.HasColumnName("start_air");
b.Property<Status?>("Status")
b.Property<Status>("Status")
.HasColumnType("status")
.HasColumnName("status");
@ -1078,7 +1123,8 @@ namespace Kyoo.Postgresql.Migrations
b.HasOne("Kyoo.Models.Studio", "Studio")
.WithMany("Shows")
.HasForeignKey("StudioID")
.HasConstraintName("fk_shows_studios_studio_id");
.HasConstraintName("fk_shows_studios_studio_id")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Studio");
});

View File

@ -12,7 +12,7 @@ namespace Kyoo.Postgresql.Migrations
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:item_type", "show,movie,collection")
.Annotation("Npgsql:Enum:status", "finished,airing,planned,unknown")
.Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.Annotation("Npgsql:Enum:stream_type", "unknown,video,audio,subtitle,attachment");
migrationBuilder.CreateTable(
@ -208,7 +208,7 @@ namespace Kyoo.Postgresql.Migrations
aliases = table.Column<string[]>(type: "text[]", nullable: true),
path = table.Column<string>(type: "text", nullable: true),
overview = table.Column<string>(type: "text", nullable: true),
status = table.Column<Status>(type: "status", nullable: true),
status = table.Column<Status>(type: "status", nullable: false),
trailer_url = table.Column<string>(type: "text", nullable: true),
start_air = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
end_air = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),

View File

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Kyoo.Postgresql.Migrations
{
[DbContext(typeof(PostgresContext))]
[Migration("20210627141941_Triggers")]
[Migration("20210723224335_Triggers")]
partial class Triggers
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -20,10 +20,10 @@ namespace Kyoo.Postgresql.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasPostgresEnum(null, "item_type", new[] { "show", "movie", "collection" })
.HasPostgresEnum(null, "status", new[] { "finished", "airing", "planned", "unknown" })
.HasPostgresEnum(null, "status", new[] { "unknown", "finished", "airing", "planned" })
.HasPostgresEnum(null, "stream_type", new[] { "unknown", "video", "audio", "subtitle", "attachment" })
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.7")
.HasAnnotation("ProductVersion", "5.0.8")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("Kyoo.Models.Collection", b =>
@ -189,6 +189,51 @@ namespace Kyoo.Postgresql.Migrations
b.ToTable("libraries");
});
modelBuilder.Entity("Kyoo.Models.LibraryItem", b =>
{
b.Property<int>("ID")
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTime?>("EndAir")
.HasColumnType("timestamp without time zone")
.HasColumnName("end_air");
b.Property<string>("Overview")
.HasColumnType("text")
.HasColumnName("overview");
b.Property<string>("Poster")
.HasColumnType("text")
.HasColumnName("poster");
b.Property<string>("Slug")
.HasColumnType("text")
.HasColumnName("slug");
b.Property<DateTime?>("StartAir")
.HasColumnType("timestamp without time zone")
.HasColumnName("start_air");
b.Property<Status?>("Status")
.HasColumnType("status")
.HasColumnName("status");
b.Property<string>("Title")
.HasColumnType("text")
.HasColumnName("title");
b.Property<ItemType>("Type")
.HasColumnType("item_type")
.HasColumnName("type");
b.HasKey("ID")
.HasName("pk_library_items");
b.ToView("library_items");
});
modelBuilder.Entity("Kyoo.Models.Link<Kyoo.Models.Collection, Kyoo.Models.Show>", b =>
{
b.Property<int>("FirstID")
@ -621,7 +666,7 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("timestamp without time zone")
.HasColumnName("start_air");
b.Property<Status?>("Status")
b.Property<Status>("Status")
.HasColumnType("status")
.HasColumnName("status");
@ -1078,7 +1123,8 @@ namespace Kyoo.Postgresql.Migrations
b.HasOne("Kyoo.Models.Studio", "Studio")
.WithMany("Shows")
.HasForeignKey("StudioID")
.HasConstraintName("fk_shows_studios_studio_id");
.HasConstraintName("fk_shows_studios_studio_id")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Studio");
});

View File

@ -91,7 +91,7 @@ namespace Kyoo.Postgresql.Migrations
END,
CASE (is_forced)
WHEN false THEN ''
ELSE '-forced'
ELSE '.forced'
END,
'.', type
) WHERE episode_id = NEW.id;
@ -117,14 +117,14 @@ namespace Kyoo.Postgresql.Migrations
END IF;
NEW.slug := CONCAT(
(SELECT slug FROM episodes WHERE id = NEW.episode_id),
'.', NEW.language,
'.', COALESCE(NEW.language, 'und'),
CASE (NEW.track_index)
WHEN 0 THEN ''
ELSE CONCAT('-', NEW.track_index)
END,
CASE (NEW.is_forced)
WHEN false THEN ''
ELSE '-forced'
ELSE '.forced'
END,
'.', NEW.type
);

View File

@ -18,10 +18,10 @@ namespace Kyoo.Postgresql.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasPostgresEnum(null, "item_type", new[] { "show", "movie", "collection" })
.HasPostgresEnum(null, "status", new[] { "finished", "airing", "planned", "unknown" })
.HasPostgresEnum(null, "status", new[] { "unknown", "finished", "airing", "planned" })
.HasPostgresEnum(null, "stream_type", new[] { "unknown", "video", "audio", "subtitle", "attachment" })
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.7")
.HasAnnotation("ProductVersion", "5.0.8")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("Kyoo.Models.Collection", b =>
@ -187,6 +187,51 @@ namespace Kyoo.Postgresql.Migrations
b.ToTable("libraries");
});
modelBuilder.Entity("Kyoo.Models.LibraryItem", b =>
{
b.Property<int>("ID")
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTime?>("EndAir")
.HasColumnType("timestamp without time zone")
.HasColumnName("end_air");
b.Property<string>("Overview")
.HasColumnType("text")
.HasColumnName("overview");
b.Property<string>("Poster")
.HasColumnType("text")
.HasColumnName("poster");
b.Property<string>("Slug")
.HasColumnType("text")
.HasColumnName("slug");
b.Property<DateTime?>("StartAir")
.HasColumnType("timestamp without time zone")
.HasColumnName("start_air");
b.Property<Status?>("Status")
.HasColumnType("status")
.HasColumnName("status");
b.Property<string>("Title")
.HasColumnType("text")
.HasColumnName("title");
b.Property<ItemType>("Type")
.HasColumnType("item_type")
.HasColumnName("type");
b.HasKey("ID")
.HasName("pk_library_items");
b.ToView("library_items");
});
modelBuilder.Entity("Kyoo.Models.Link<Kyoo.Models.Collection, Kyoo.Models.Show>", b =>
{
b.Property<int>("FirstID")
@ -619,7 +664,7 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("timestamp without time zone")
.HasColumnName("start_air");
b.Property<Status?>("Status")
b.Property<Status>("Status")
.HasColumnType("status")
.HasColumnName("status");
@ -1076,7 +1121,8 @@ namespace Kyoo.Postgresql.Migrations
b.HasOne("Kyoo.Models.Studio", "Studio")
.WithMany("Shows")
.HasForeignKey("StudioID")
.HasConstraintName("fk_shows_studios_studio_id");
.HasConstraintName("fk_shows_studios_studio_id")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Studio");
});

View File

@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
namespace Kyoo.Postgresql
{
@ -73,6 +74,10 @@ namespace Kyoo.Postgresql
{
DatabaseContext context = provider.GetRequiredService<DatabaseContext>();
context.Database.Migrate();
using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection();
conn.Open();
conn.ReloadTypes();
}
}
}

View File

@ -9,14 +9,14 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Kyoo.SqLite.Migrations
{
[DbContext(typeof(SqLiteContext))]
[Migration("20210626141337_Initial")]
[Migration("20210723224542_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.7");
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("Kyoo.Models.Collection", b =>
{
@ -143,6 +143,41 @@ namespace Kyoo.SqLite.Migrations
b.ToTable("Libraries");
});
modelBuilder.Entity("Kyoo.Models.LibraryItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("EndAir")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("Slug")
.HasColumnType("TEXT");
b.Property<DateTime?>("StartAir")
.HasColumnType("TEXT");
b.Property<int?>("Status")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("ID");
b.ToView("LibraryItems");
});
modelBuilder.Entity("Kyoo.Models.Link<Kyoo.Models.Collection, Kyoo.Models.Show>", b =>
{
b.Property<int>("FirstID")
@ -477,7 +512,7 @@ namespace Kyoo.SqLite.Migrations
b.Property<DateTime?>("StartAir")
.HasColumnType("TEXT");
b.Property<int?>("Status")
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<int?>("StudioID")
@ -864,7 +899,8 @@ namespace Kyoo.SqLite.Migrations
{
b.HasOne("Kyoo.Models.Studio", "Studio")
.WithMany("Shows")
.HasForeignKey("StudioID");
.HasForeignKey("StudioID")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Studio");
});

View File

@ -200,7 +200,7 @@ namespace Kyoo.SqLite.Migrations
Aliases = table.Column<string>(type: "TEXT", nullable: true),
Path = table.Column<string>(type: "TEXT", nullable: true),
Overview = table.Column<string>(type: "TEXT", nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
TrailerUrl = table.Column<string>(type: "TEXT", nullable: true),
StartAir = table.Column<DateTime>(type: "TEXT", nullable: true),
EndAir = table.Column<DateTime>(type: "TEXT", nullable: true),

View File

@ -9,14 +9,14 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Kyoo.SqLite.Migrations
{
[DbContext(typeof(SqLiteContext))]
[Migration("20210626141347_Triggers")]
[Migration("20210723224550_Triggers")]
partial class Triggers
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.7");
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("Kyoo.Models.Collection", b =>
{
@ -143,6 +143,41 @@ namespace Kyoo.SqLite.Migrations
b.ToTable("Libraries");
});
modelBuilder.Entity("Kyoo.Models.LibraryItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("EndAir")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("Slug")
.HasColumnType("TEXT");
b.Property<DateTime?>("StartAir")
.HasColumnType("TEXT");
b.Property<int?>("Status")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("ID");
b.ToView("LibraryItems");
});
modelBuilder.Entity("Kyoo.Models.Link<Kyoo.Models.Collection, Kyoo.Models.Show>", b =>
{
b.Property<int>("FirstID")
@ -477,7 +512,7 @@ namespace Kyoo.SqLite.Migrations
b.Property<DateTime?>("StartAir")
.HasColumnType("TEXT");
b.Property<int?>("Status")
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<int?>("StudioID")
@ -864,7 +899,8 @@ namespace Kyoo.SqLite.Migrations
{
b.HasOne("Kyoo.Models.Studio", "Studio")
.WithMany("Shows")
.HasForeignKey("StudioID");
.HasForeignKey("StudioID")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Studio");
});

View File

@ -61,14 +61,14 @@ namespace Kyoo.SqLite.Migrations
AND Language = new.Language AND IsForced = new.IsForced
) WHERE ID = new.ID AND TrackIndex = 0;
UPDATE Tracks SET Slug = (SELECT Slug FROM Episodes WHERE ID = EpisodeID) ||
'.' || Language ||
'.' || COALESCE(Language, 'und') ||
CASE (TrackIndex)
WHEN 0 THEN ''
ELSE '-' || (TrackIndex)
END ||
CASE (IsForced)
WHEN false THEN ''
ELSE '-forced'
ELSE '.forced'
END ||
CASE (Type)
WHEN 1 THEN '.video'
@ -98,7 +98,7 @@ namespace Kyoo.SqLite.Migrations
END ||
CASE (IsForced)
WHEN false THEN ''
ELSE '-forced'
ELSE '.forced'
END ||
CASE (Type)
WHEN 1 THEN '.video'
@ -123,7 +123,7 @@ namespace Kyoo.SqLite.Migrations
END ||
CASE (IsForced)
WHEN false THEN ''
ELSE '-forced'
ELSE '.forced'
END ||
CASE (Type)
WHEN 1 THEN '.video'

View File

@ -14,7 +14,7 @@ namespace Kyoo.SqLite.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.7");
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("Kyoo.Models.Collection", b =>
{
@ -141,6 +141,41 @@ namespace Kyoo.SqLite.Migrations
b.ToTable("Libraries");
});
modelBuilder.Entity("Kyoo.Models.LibraryItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("EndAir")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("Slug")
.HasColumnType("TEXT");
b.Property<DateTime?>("StartAir")
.HasColumnType("TEXT");
b.Property<int?>("Status")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("ID");
b.ToView("LibraryItems");
});
modelBuilder.Entity("Kyoo.Models.Link<Kyoo.Models.Collection, Kyoo.Models.Show>", b =>
{
b.Property<int>("FirstID")
@ -475,7 +510,7 @@ namespace Kyoo.SqLite.Migrations
b.Property<DateTime?>("StartAir")
.HasColumnType("TEXT");
b.Property<int?>("Status")
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<int?>("StudioID")
@ -862,7 +897,8 @@ namespace Kyoo.SqLite.Migrations
{
b.HasOne("Kyoo.Models.Studio", "Studio")
.WithMany("Shows")
.HasForeignKey("StudioID");
.HasForeignKey("StudioID")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Studio");
});

View File

@ -25,6 +25,7 @@ namespace Kyoo.Tests
public void Dispose()
{
Repositories.Dispose();
GC.SuppressFinalize(this);
}
public ValueTask DisposeAsync()

View File

@ -3,7 +3,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -4,7 +4,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -3,7 +3,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -5,7 +5,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -1,8 +1,11 @@
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{
@ -23,7 +26,7 @@ namespace Kyoo.Tests.Library
}
}
public abstract class ALibraryTests : RepositoryTests<Models.Library>
public abstract class ALibraryTests : RepositoryTests<Library>
{
private readonly ILibraryRepository _repository;
@ -32,5 +35,17 @@ namespace Kyoo.Tests.Library
{
_repository = Repositories.LibraryManager.LibraryRepository;
}
[Fact]
public async Task CreateWithProvider()
{
Library library = TestSample.GetNew<Library>();
library.Providers = new[] { TestSample.Get<Provider>() };
await _repository.Create(library);
Library retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.Providers);
Assert.Equal(1, retrieved.Providers.Count);
Assert.Equal(TestSample.Get<Provider>().Slug, retrieved.Providers.First().Slug);
}
}
}

View File

@ -3,7 +3,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -3,7 +3,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -5,7 +5,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
public class GlobalTests : IDisposable, IAsyncDisposable
{

View File

@ -4,7 +4,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -3,7 +3,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -4,7 +4,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{
@ -47,5 +47,20 @@ namespace Kyoo.Tests.Library
Track track = await _repository.Get(1);
Assert.Equal("new-slug-s1e1.eng-1.subtitle", track.Slug);
}
[Fact]
public async Task UndefinedLanguageSlugTest()
{
await _repository.Create(new Track
{
ID = 5,
TrackIndex = 0,
Type = StreamType.Video,
Language = null,
EpisodeID = TestSample.Get<Episode>().ID
});
Track track = await _repository.Get(5);
Assert.Equal("anohana-s1e1.und.video", track.Slug);
}
}
}

View File

@ -3,7 +3,7 @@ using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Library
namespace Kyoo.Tests.Database
{
namespace SqLite
{

View File

@ -9,8 +9,14 @@ namespace Kyoo.Tests
private static readonly Dictionary<Type, Func<object>> NewSamples = new()
{
{
typeof(Show),
() => new Show()
typeof(Library),
() => new Library
{
ID = 2,
Slug = "new-library",
Name = "New Library",
Paths = new [] {"/a/random/path"}
}
}
};
@ -18,8 +24,8 @@ namespace Kyoo.Tests
private static readonly Dictionary<Type, Func<object>> Samples = new()
{
{
typeof(Models.Library),
() => new Models.Library
typeof(Library),
() => new Library
{
ID = 1,
Slug = "deck",
@ -227,7 +233,7 @@ namespace Kyoo.Tests
provider.ID = 0;
context.Providers.Add(provider);
Models.Library library = Get<Models.Library>();
Library library = Get<Library>();
library.ID = 0;
library.Collections = new List<Collection> {collection};
library.Providers = new List<Provider> {provider};

View File

@ -0,0 +1,192 @@
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Kyoo.Models.Exceptions;
using Kyoo.Models.Options;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Kyoo.Tests.Identifier
{
public class Identifier
{
private readonly Mock<ILibraryManager> _manager;
private readonly IIdentifier _identifier;
public Identifier()
{
Mock<IOptionsMonitor<MediaOptions>> options = new();
options.Setup(x => x.CurrentValue).Returns(new MediaOptions
{
Regex = new []
{
"^\\/?(?<Collection>.+)?\\/(?<Show>.+?)(?: \\((?<StartYear>\\d+)\\))?\\/\\k<Show>(?: \\(\\d+\\))? S(?<Season>\\d+)E(?<Episode>\\d+)\\..*$",
"^\\/?(?<Collection>.+)?\\/(?<Show>.+?)(?: \\((?<StartYear>\\d+)\\))?\\/\\k<Show>(?: \\(\\d+\\))? (?<Absolute>\\d+)\\..*$",
"^\\/?(?<Collection>.+)?\\/(?<Show>.+?)(?: \\((?<StartYear>\\d+)\\))?\\/\\k<Show>(?: \\(\\d+\\))?\\..*$"
},
SubtitleRegex = new[]
{
"^(?<Episode>.+)\\.(?<Language>\\w{1,3})\\.(?<Default>default\\.)?(?<Forced>forced\\.)?.*$"
}
});
_manager = new Mock<ILibraryManager>();
_identifier = new RegexIdentifier(options.Object, _manager.Object);
}
[Fact]
public async Task EpisodeIdentification()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo/Library/"}}
});
(Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(
"/kyoo/Library/Collection/Show (2000)/Show S01E01.extension");
Assert.Equal("Collection", collection.Name);
Assert.Equal("collection", collection.Slug);
Assert.Equal("Show", show.Title);
Assert.Equal("show", show.Slug);
Assert.Equal(2000, show.StartAir!.Value.Year);
Assert.Equal(1, season.SeasonNumber);
Assert.Equal(1, episode.SeasonNumber);
Assert.Equal(1, episode.EpisodeNumber);
Assert.Null(episode.AbsoluteNumber);
}
[Fact]
public async Task EpisodeIdentificationWithoutLibraryTrailingSlash()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo/Library"}}
});
(Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(
"/kyoo/Library/Collection/Show (2000)/Show S01E01.extension");
Assert.Equal("Collection", collection.Name);
Assert.Equal("collection", collection.Slug);
Assert.Equal("Show", show.Title);
Assert.Equal("show", show.Slug);
Assert.Equal(2000, show.StartAir!.Value.Year);
Assert.Equal(1, season.SeasonNumber);
Assert.Equal(1, episode.SeasonNumber);
Assert.Equal(1, episode.EpisodeNumber);
Assert.Null(episode.AbsoluteNumber);
}
[Fact]
public async Task EpisodeIdentificationMultiplePaths()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}}
});
(Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(
"/kyoo/Library/Collection/Show (2000)/Show S01E01.extension");
Assert.Equal("Collection", collection.Name);
Assert.Equal("collection", collection.Slug);
Assert.Equal("Show", show.Title);
Assert.Equal("show", show.Slug);
Assert.Equal(2000, show.StartAir!.Value.Year);
Assert.Equal(1, season.SeasonNumber);
Assert.Equal(1, episode.SeasonNumber);
Assert.Equal(1, episode.EpisodeNumber);
Assert.Null(episode.AbsoluteNumber);
}
[Fact]
public async Task AbsoluteEpisodeIdentification()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}}
});
(Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(
"/kyoo/Library/Collection/Show (2000)/Show 100.extension");
Assert.Equal("Collection", collection.Name);
Assert.Equal("collection", collection.Slug);
Assert.Equal("Show", show.Title);
Assert.Equal("show", show.Slug);
Assert.Equal(2000, show.StartAir!.Value.Year);
Assert.Null(season);
Assert.Null(episode.SeasonNumber);
Assert.Null(episode.EpisodeNumber);
Assert.Equal(100, episode.AbsoluteNumber);
}
[Fact]
public async Task MovieEpisodeIdentification()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}}
});
(Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(
"/kyoo/Library/Collection/Show (2000)/Show.extension");
Assert.Equal("Collection", collection.Name);
Assert.Equal("collection", collection.Slug);
Assert.Equal("Show", show.Title);
Assert.Equal("show", show.Slug);
Assert.Equal(2000, show.StartAir!.Value.Year);
Assert.Null(season);
Assert.True(show.IsMovie);
Assert.Null(episode.SeasonNumber);
Assert.Null(episode.EpisodeNumber);
Assert.Null(episode.AbsoluteNumber);
}
[Fact]
public async Task InvalidEpisodeIdentification()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}}
});
await Assert.ThrowsAsync<IdentificationFailedException>(() => _identifier.Identify("/invalid/path"));
}
[Fact]
public async Task SubtitleIdentification()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}}
});
Track track = await _identifier.IdentifyTrack("/kyoo/Library/Collection/Show (2000)/Show.eng.default.str");
Assert.True(track.IsExternal);
Assert.Equal("eng", track.Language);
Assert.Equal("subrip", track.Codec);
Assert.True(track.IsDefault);
Assert.False(track.IsForced);
Assert.StartsWith("/kyoo/Library/Collection/Show (2000)/Show", track.Episode.Path);
}
[Fact]
public async Task SubtitleIdentificationUnknownCodec()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}}
});
Track track = await _identifier.IdentifyTrack("/kyoo/Library/Collection/Show (2000)/Show.eng.default.extension");
Assert.True(track.IsExternal);
Assert.Equal("eng", track.Language);
Assert.Equal("extension", track.Codec);
Assert.True(track.IsDefault);
Assert.False(track.IsForced);
Assert.StartsWith("/kyoo/Library/Collection/Show (2000)/Show", track.Episode.Path);
}
[Fact]
public async Task InvalidSubtitleIdentification()
{
_manager.Setup(x => x.GetAll(null, new Sort<Library>(), default)).ReturnsAsync(new[]
{
new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}}
});
await Assert.ThrowsAsync<IdentificationFailedException>(() => _identifier.IdentifyTrack("/invalid/path"));
}
}
}

View File

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Identifier
{
public class ProviderTests
{
private readonly ILoggerFactory _factory;
public ProviderTests(ITestOutputHelper output)
{
_factory = LoggerFactory.Create(x =>
{
x.ClearProviders();
x.AddXunit(output);
});
}
[Fact]
public async Task NoProviderGetTest()
{
AProviderComposite provider = new ProviderComposite(Array.Empty<IMetadataProvider>(),
_factory.CreateLogger<ProviderComposite>());
Show show = new()
{
ID = 4,
Genres = new[] { new Genre("genre") }
};
Show ret = await provider.Get(show);
KAssert.DeepEqual(show, ret);
}
[Fact]
public async Task NoProviderSearchTest()
{
AProviderComposite provider = new ProviderComposite(Array.Empty<IMetadataProvider>(),
_factory.CreateLogger<ProviderComposite>());
ICollection<Show> ret = await provider.Search<Show>("show");
Assert.Empty(ret);
}
[Fact]
public async Task OneProviderGetTest()
{
Show show = new()
{
ID = 4,
Genres = new[] { new Genre("genre") }
};
Mock<IMetadataProvider> mock = new();
mock.Setup(x => x.Get(show)).ReturnsAsync(new Show
{
Title = "title",
Genres = new[] { new Genre("ToMerge")}
});
AProviderComposite provider = new ProviderComposite(new []
{
mock.Object
},
_factory.CreateLogger<ProviderComposite>());
Show ret = await provider.Get(show);
Assert.Equal(4, ret.ID);
Assert.Equal("title", ret.Title);
Assert.Equal(2, ret.Genres.Count);
Assert.Contains("genre", ret.Genres.Select(x => x.Slug));
Assert.Contains("tomerge", ret.Genres.Select(x => x.Slug));
}
[Fact]
public async Task FailingProviderGetTest()
{
Show show = new()
{
ID = 4,
Genres = new[] { new Genre("genre") }
};
Mock<IMetadataProvider> mock = new();
mock.Setup(x => x.Provider).Returns(new Provider("mock", ""));
mock.Setup(x => x.Get(show)).ReturnsAsync(new Show
{
Title = "title",
Genres = new[] { new Genre("ToMerge")}
});
Mock<IMetadataProvider> mockTwo = new();
mockTwo.Setup(x => x.Provider).Returns(new Provider("mockTwo", ""));
mockTwo.Setup(x => x.Get(show)).ReturnsAsync(new Show
{
Title = "title2",
Status = Status.Finished,
Genres = new[] { new Genre("ToMerge")}
});
Mock<IMetadataProvider> mockFailing = new();
mockFailing.Setup(x => x.Provider).Returns(new Provider("mockFail", ""));
mockFailing.Setup(x => x.Get(show)).Throws<ArgumentException>();
AProviderComposite provider = new ProviderComposite(new []
{
mock.Object,
mockTwo.Object,
mockFailing.Object
},
_factory.CreateLogger<ProviderComposite>());
Show ret = await provider.Get(show);
Assert.Equal(4, ret.ID);
Assert.Equal("title", ret.Title);
Assert.Equal(Status.Finished, ret.Status);
Assert.Equal(2, ret.Genres.Count);
Assert.Contains("genre", ret.Genres.Select(x => x.Slug));
Assert.Contains("tomerge", ret.Genres.Select(x => x.Slug));
}
}
}

View File

@ -0,0 +1,160 @@
using System;
using System.Linq;
using Kyoo.Models;
using Kyoo.TheTvdb;
using TvDbSharper.Dto;
using Xunit;
namespace Kyoo.Tests.Identifier.Tvdb
{
public class ConvertorTests
{
[Fact]
public void SeriesSearchToShow()
{
SeriesSearchResult result = new()
{
Slug = "slug",
SeriesName = "name",
Aliases = new[] { "Aliases" },
Overview = "overview",
Status = "Ended",
FirstAired = "2021-07-23",
Poster = "/poster",
Id = 5
};
Provider provider = TestSample.Get<Provider>();
Show show = result.ToShow(provider);
Assert.Equal("slug", show.Slug);
Assert.Equal("name", show.Title);
Assert.Single(show.Aliases);
Assert.Equal("Aliases", show.Aliases[0]);
Assert.Equal("overview", show.Overview);
Assert.Equal(new DateTime(2021, 7, 23), show.StartAir);
Assert.Equal("https://www.thetvdb.com/poster", show.Poster);
Assert.Single(show.ExternalIDs);
Assert.Equal("5", show.ExternalIDs.First().DataID);
Assert.Equal(provider, show.ExternalIDs.First().Provider);
Assert.Equal("https://www.thetvdb.com/series/slug", show.ExternalIDs.First().Link);
Assert.Equal(Status.Finished, show.Status);
}
[Fact]
public void SeriesSearchToShowInvalidDate()
{
SeriesSearchResult result = new()
{
Slug = "slug",
SeriesName = "name",
Aliases = new[] { "Aliases" },
Overview = "overview",
Status = "ad",
FirstAired = "2e021-07-23",
Poster = "/poster",
Id = 5
};
Provider provider = TestSample.Get<Provider>();
Show show = result.ToShow(provider);
Assert.Equal("slug", show.Slug);
Assert.Equal("name", show.Title);
Assert.Single(show.Aliases);
Assert.Equal("Aliases", show.Aliases[0]);
Assert.Equal("overview", show.Overview);
Assert.Null(show.StartAir);
Assert.Equal("https://www.thetvdb.com/poster", show.Poster);
Assert.Single(show.ExternalIDs);
Assert.Equal("5", show.ExternalIDs.First().DataID);
Assert.Equal(provider, show.ExternalIDs.First().Provider);
Assert.Equal("https://www.thetvdb.com/series/slug", show.ExternalIDs.First().Link);
Assert.Equal(Status.Unknown, show.Status);
}
[Fact]
public void SeriesToShow()
{
Series result = new()
{
Slug = "slug",
SeriesName = "name",
Aliases = new[] { "Aliases" },
Overview = "overview",
Status = "Continuing",
FirstAired = "2021-07-23",
Poster = "poster",
FanArt = "fanart",
Id = 5,
Genre = new []
{
"Action",
"Test With Spéàacial characters"
}
};
Provider provider = TestSample.Get<Provider>();
Show show = result.ToShow(provider);
Assert.Equal("slug", show.Slug);
Assert.Equal("name", show.Title);
Assert.Single(show.Aliases);
Assert.Equal("Aliases", show.Aliases[0]);
Assert.Equal("overview", show.Overview);
Assert.Equal(new DateTime(2021, 7, 23), show.StartAir);
Assert.Equal("https://www.thetvdb.com/banners/poster", show.Poster);
Assert.Equal("https://www.thetvdb.com/banners/fanart", show.Backdrop);
Assert.Single(show.ExternalIDs);
Assert.Equal("5", show.ExternalIDs.First().DataID);
Assert.Equal(provider, show.ExternalIDs.First().Provider);
Assert.Equal("https://www.thetvdb.com/series/slug", show.ExternalIDs.First().Link);
Assert.Equal(Status.Airing, show.Status);
Assert.Equal(2, show.Genres.Count);
Assert.Equal("action", show.Genres.ToArray()[0].Slug);
Assert.Equal("Action", show.Genres.ToArray()[0].Name);
Assert.Equal("Test With Spéàacial characters", show.Genres.ToArray()[1].Name);
Assert.Equal("test-with-speaacial-characters", show.Genres.ToArray()[1].Slug);
}
[Fact]
public void ActorToPeople()
{
Actor actor = new()
{
Id = 5,
Image = "image",
Name = "Name",
Role = "role"
};
Provider provider = TestSample.Get<Provider>();
PeopleRole people = actor.ToPeopleRole(provider);
Assert.Equal("name", people.Slug);
Assert.Equal("Name", people.People.Name);
Assert.Equal("role", people.Role);
Assert.Equal("https://www.thetvdb.com/banners/image", people.People.Poster);
}
[Fact]
public void EpisodeRecordToEpisode()
{
EpisodeRecord record = new()
{
Id = 5,
AiredSeason = 2,
AiredEpisodeNumber = 3,
AbsoluteNumber = 23,
EpisodeName = "title",
Overview = "overview",
Filename = "thumb"
};
Provider provider = TestSample.Get<Provider>();
Episode episode = record.ToEpisode(provider);
Assert.Equal("title", episode.Title);
Assert.Equal(2, episode.SeasonNumber);
Assert.Equal(3, episode.EpisodeNumber);
Assert.Equal(23, episode.AbsoluteNumber);
Assert.Equal("overview", episode.Overview);
Assert.Equal("https://www.thetvdb.com/banners/thumb", episode.Thumb);
}
}
}

View File

@ -17,6 +17,8 @@
<PackageReference Include="Divergic.Logging.Xunit" Version="3.6.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="TvDbSharper" Version="3.2.2" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -32,6 +34,7 @@
<ProjectReference Include="../Kyoo.CommonAPI/Kyoo.CommonAPI.csproj" />
<ProjectReference Include="../Kyoo.Common/Kyoo.Common.csproj" />
<ProjectReference Include="../Kyoo/Kyoo.csproj" />
<ProjectReference Include="..\Kyoo.TheTvdb\Kyoo.TheTvdb.csproj" />
</ItemGroup>
</Project>

View File

@ -4,7 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using Xunit;
namespace Kyoo.Tests
namespace Kyoo.Tests.Utility
{
public class EnumerableTests
{

View File

@ -1,7 +1,11 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Kyoo.Models;
using Kyoo.Models.Attributes;
using Xunit;
namespace Kyoo.Tests
namespace Kyoo.Tests.Utility
{
public class MergerTests
{
@ -17,5 +21,192 @@ namespace Kyoo.Tests
Assert.Null(genre.Name);
Assert.Null(genre.Slug);
}
[Fact]
public void MergeTest()
{
Genre genre = new()
{
ID = 5
};
Genre genre2 = new()
{
Name = "test"
};
Genre ret = Merger.Merge(genre, genre2);
Assert.True(ReferenceEquals(genre, ret));
Assert.Equal(5, ret.ID);
Assert.Equal("test", genre.Name);
Assert.Null(genre.Slug);
}
[Fact]
[SuppressMessage("ReSharper", "ExpressionIsAlwaysNull")]
public void MergeNullTests()
{
Genre genre = new()
{
ID = 5
};
Assert.True(ReferenceEquals(genre, Merger.Merge(genre, null)));
Assert.True(ReferenceEquals(genre, Merger.Merge(null, genre)));
Assert.Null(Merger.Merge<Genre>(null, null));
}
private class TestIOnMerge : IOnMerge
{
public void OnMerge(object other)
{
Exception exception = new();
exception.Data[0] = other;
throw exception;
}
}
[Fact]
public void OnMergeTest()
{
TestIOnMerge test = new();
TestIOnMerge test2 = new();
Assert.Throws<Exception>(() => Merger.Merge(test, test2));
try
{
Merger.Merge(test, test2);
}
catch (Exception ex)
{
Assert.True(ReferenceEquals(test2, ex.Data[0]));
}
}
private class Test
{
public int ID { get; set; }
public int[] Numbers { get; set; }
}
[Fact]
public void GlobalMergeListTest()
{
Test test = new()
{
ID = 5,
Numbers = new [] { 1 }
};
Test test2 = new()
{
Numbers = new [] { 3 }
};
Test ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(2, ret.Numbers.Length);
Assert.Equal(1, ret.Numbers[0]);
Assert.Equal(3, ret.Numbers[1]);
}
[Fact]
public void GlobalMergeListDuplicatesTest()
{
Test test = new()
{
ID = 5,
Numbers = new [] { 1 }
};
Test test2 = new()
{
Numbers = new []
{
1,
3,
3
}
};
Test ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(4, ret.Numbers.Length);
Assert.Equal(1, ret.Numbers[0]);
Assert.Equal(1, ret.Numbers[1]);
Assert.Equal(3, ret.Numbers[2]);
Assert.Equal(3, ret.Numbers[3]);
}
[Fact]
public void GlobalMergeListDuplicatesResourcesTest()
{
Show test = new()
{
ID = 5,
Genres = new [] { new Genre("test") }
};
Show test2 = new()
{
Genres = new []
{
new Genre("test"),
new Genre("test2")
}
};
Show ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(2, ret.Genres.Count);
Assert.Equal("test", ret.Genres.ToArray()[0].Slug);
Assert.Equal("test2", ret.Genres.ToArray()[1].Slug);
}
[Fact]
public void MergeListTest()
{
int[] first = { 1 };
int[] second = {
3,
3
};
int[] ret = Merger.MergeLists(first, second);
Assert.Equal(3, ret.Length);
Assert.Equal(1, ret[0]);
Assert.Equal(3, ret[1]);
Assert.Equal(3, ret[2]);
}
[Fact]
public void MergeListDuplicateTest()
{
int[] first = { 1 };
int[] second = {
1,
3,
3
};
int[] ret = Merger.MergeLists(first, second);
Assert.Equal(4, ret.Length);
Assert.Equal(1, ret[0]);
Assert.Equal(1, ret[1]);
Assert.Equal(3, ret[2]);
Assert.Equal(3, ret[3]);
}
[Fact]
public void MergeListDuplicateCustomEqualityTest()
{
int[] first = { 1 };
int[] second = {
3,
2
};
int[] ret = Merger.MergeLists(first, second, (x, y) => x % 2 == y % 2);
Assert.Equal(2, ret.Length);
Assert.Equal(1, ret[0]);
Assert.Equal(2, ret[1]);
}
}
}

View File

@ -3,7 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace Kyoo.Tests
namespace Kyoo.Tests.Utility
{
public class TaskTests
{

View File

@ -1,9 +1,12 @@
using System;
using System.Linq.Expressions;
using System.Reflection;
using Kyoo.Models;
using Xunit;
namespace Kyoo.Tests
using Utils = Kyoo.Utility;
namespace Kyoo.Tests.Utility
{
public class UtilityTests
{
@ -13,12 +16,12 @@ namespace Kyoo.Tests
Expression<Func<Show, int>> member = x => x.ID;
Expression<Func<Show, object>> memberCast = x => x.ID;
Assert.False(Utility.IsPropertyExpression(null));
Assert.True(Utility.IsPropertyExpression(member));
Assert.True(Utility.IsPropertyExpression(memberCast));
Assert.False(Utils.IsPropertyExpression(null));
Assert.True(Utils.IsPropertyExpression(member));
Assert.True(Utils.IsPropertyExpression(memberCast));
Expression<Func<Show, object>> call = x => x.GetID("test");
Assert.False(Utility.IsPropertyExpression(call));
Assert.False(Utils.IsPropertyExpression(call));
}
[Fact]
@ -27,9 +30,51 @@ namespace Kyoo.Tests
Expression<Func<Show, int>> member = x => x.ID;
Expression<Func<Show, object>> memberCast = x => x.ID;
Assert.Equal("ID", Utility.GetPropertyName(member));
Assert.Equal("ID", Utility.GetPropertyName(memberCast));
Assert.Throws<ArgumentException>(() => Utility.GetPropertyName(null));
Assert.Equal("ID", Utils.GetPropertyName(member));
Assert.Equal("ID", Utils.GetPropertyName(memberCast));
Assert.Throws<ArgumentException>(() => Utils.GetPropertyName(null));
}
[Fact]
public void GetMethodTest()
{
MethodInfo method = Utils.GetMethod(typeof(UtilityTests),
BindingFlags.Instance | BindingFlags.Public,
nameof(GetMethodTest),
Array.Empty<Type>(),
Array.Empty<object>());
Assert.Equal(MethodBase.GetCurrentMethod(), method);
}
[Fact]
public void GetMethodInvalidGenericsTest()
{
Assert.Throws<ArgumentException>(() => Utils.GetMethod(typeof(UtilityTests),
BindingFlags.Instance | BindingFlags.Public,
nameof(GetMethodTest),
new [] { typeof(Utils) },
Array.Empty<object>()));
}
[Fact]
public void GetMethodInvalidParamsTest()
{
Assert.Throws<ArgumentException>(() => Utils.GetMethod(typeof(UtilityTests),
BindingFlags.Instance | BindingFlags.Public,
nameof(GetMethodTest),
Array.Empty<Type>(),
new object[] { this }));
}
[Fact]
public void GetMethodTest2()
{
MethodInfo method = Utils.GetMethod(typeof(Merger),
BindingFlags.Static | BindingFlags.Public,
nameof(Merger.MergeLists),
new [] { typeof(string) },
new object[] { "string", "string2", null });
Assert.Equal(nameof(Merger.MergeLists), method.Name);
}
}
}

160
Kyoo.TheTvdb/Convertors.cs Normal file
View File

@ -0,0 +1,160 @@
using System;
using System.Globalization;
using System.Linq;
using Kyoo.Models;
using TvDbSharper.Dto;
namespace Kyoo.TheTvdb
{
/// <summary>
/// A set of extensions methods used to convert tvdb models to Kyoo models.
/// </summary>
public static class Convertors
{
/// <summary>
/// Convert the string representation of the status in the tvdb API to a Kyoo's <see cref="Status"/> enum.
/// </summary>
/// <param name="status">The string representing the status.</param>
/// <returns>A kyoo <see cref="Status"/> value or null.</returns>
private static Status _GetStatus(string status)
{
return status switch
{
"Ended" => Status.Finished,
"Continuing" => Status.Airing,
_ => Status.Unknown
};
}
/// <summary>
/// Parse a TVDB date and return a <see cref="DateTime"/> or null if the string is invalid.
/// </summary>
/// <param name="date">The date string to parse</param>
/// <returns>The parsed <see cref="DateTime"/> or null.</returns>
private static DateTime? _ParseDate(string date)
{
return DateTime.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out DateTime parsed)
? parsed
: null;
}
/// <summary>
/// Convert a series search to a show.
/// </summary>
/// <param name="result">The search result</param>
/// <param name="provider">The provider representing the tvdb inside kyoo</param>
/// <returns>A show representing the given search result.</returns>
public static Show ToShow(this SeriesSearchResult result, Provider provider)
{
return new()
{
Slug = result.Slug,
Title = result.SeriesName,
Aliases = result.Aliases,
Overview = result.Overview,
Status = _GetStatus(result.Status),
StartAir = _ParseDate(result.FirstAired),
Poster = result.Poster != null ? $"https://www.thetvdb.com{result.Poster}" : null,
ExternalIDs = new[]
{
new MetadataID<Show>
{
DataID = result.Id.ToString(),
Link = $"https://www.thetvdb.com/series/{result.Slug}",
Second = provider
}
}
};
}
/// <summary>
/// Convert a tvdb series to a kyoo show.
/// </summary>
/// <param name="series">The series to convert</param>
/// <param name="provider">The provider representing the tvdb inside kyoo</param>
/// <returns>A show representing the given series.</returns>
public static Show ToShow(this Series series, Provider provider)
{
return new()
{
Slug = series.Slug,
Title = series.SeriesName,
Aliases = series.Aliases,
Overview = series.Overview,
Status = _GetStatus(series.Status),
StartAir = _ParseDate(series.FirstAired),
Poster = series.Poster != null ? $"https://www.thetvdb.com/banners/{series.Poster}" : null,
Backdrop = series.FanArt != null ? $"https://www.thetvdb.com/banners/{series.FanArt}" : null,
Genres = series.Genre.Select(y => new Genre(y)).ToList(),
ExternalIDs = new[]
{
new MetadataID<Show>
{
DataID = series.Id.ToString(),
Link = $"https://www.thetvdb.com/series/{series.Slug}",
Second = provider
}
}
};
}
/// <summary>
/// Convert a tvdb actor to a kyoo <see cref="PeopleRole"/>.
/// </summary>
/// <param name="actor">The actor to convert</param>
/// <param name="provider">The provider representing the tvdb inside kyoo</param>
/// <returns>A people role representing the given actor in the role they played.</returns>
public static PeopleRole ToPeopleRole(this Actor actor, Provider provider)
{
return new()
{
People = new People
{
Slug = Utility.ToSlug(actor.Name),
Name = actor.Name,
Poster = actor.Image != null ? $"https://www.thetvdb.com/banners/{actor.Image}" : null,
ExternalIDs = new []
{
new MetadataID<People>()
{
DataID = actor.Id.ToString(),
Link = $"https://www.thetvdb.com/people/{actor.Id}",
Second = provider
}
}
},
Role = actor.Role,
Type = "Actor"
};
}
/// <summary>
/// Convert a tvdb episode to a kyoo <see cref="Episode"/>.
/// </summary>
/// <param name="episode">The episode to convert</param>
/// <param name="provider">The provider representing the tvdb inside kyoo</param>
/// <returns>A episode representing the given tvdb episode.</returns>
public static Episode ToEpisode(this EpisodeRecord episode, Provider provider)
{
return new()
{
SeasonNumber = episode.AiredSeason,
EpisodeNumber = episode.AiredEpisodeNumber,
AbsoluteNumber = episode.AbsoluteNumber,
Title = episode.EpisodeName,
Overview = episode.Overview,
Thumb = episode.Filename != null ? $"https://www.thetvdb.com/banners/{episode.Filename}" : null,
ExternalIDs = new[]
{
new MetadataID<Episode>
{
DataID = episode.Id.ToString(),
Link = $"https://www.thetvdb.com/series/{episode.SeriesId}/episodes/{episode.Id}",
Second = provider
}
}
};
}
}
}

View File

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Company>SDG</Company>
<Authors>Zoe Roux</Authors>
<RepositoryUrl>https://github.com/AnonymusRaccoon/Kyoo</RepositoryUrl>
<LangVersion>default</LangVersion>
<RootNamespace>Kyoo.TheTvdb</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<OutputPath>../Kyoo/bin/$(Configuration)/$(TargetFramework)/plugins/the-tvdb</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<GenerateDependencyFile>false</GenerateDependencyFile>
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="5.0.0" />
<PackageReference Include="TvDbSharper" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Kyoo.Common/Kyoo.Common.csproj">
<PrivateAssets>all</PrivateAssets>
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using Autofac;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
using Kyoo.TheTvdb.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TvDbSharper;
namespace Kyoo.TheTvdb
{
/// <summary>
/// A plugin that add a <see cref="IMetadataProvider"/> for The TVDB.
/// </summary>
public class PluginTvdb : IPlugin
{
/// <inheritdoc />
public string Slug => "the-tvdb";
/// <inheritdoc />
public string Name => "The TVDB Provider";
/// <inheritdoc />
public string Description => "A metadata provider for The TVDB.";
/// <inheritdoc />
public ICollection<Type> Provides => new []
{
typeof(IMetadataProvider)
};
/// <inheritdoc />
public ICollection<ConditionalProvide> ConditionalProvides => ArraySegment<ConditionalProvide>.Empty;
/// <inheritdoc />
public ICollection<Type> Requires => ArraySegment<Type>.Empty;
/// <summary>
/// The configuration to use.
/// </summary>
private readonly IConfiguration _configuration;
/// <summary>
/// The configuration manager used to register typed/untyped implementations.
/// </summary>
[Injected] public IConfigurationManager ConfigurationManager { private get; set; }
/// <summary>
/// Create a new tvdb module instance and use the given configuration.
/// </summary>
/// <param name="configuration">The configuration to use</param>
public PluginTvdb(IConfiguration configuration)
{
_configuration = configuration;
}
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder.RegisterType<TvDbClient>().As<ITvDbClient>();
builder.RegisterProvider<ProviderTvdb>();
}
/// <inheritdoc />
public void Configure(IServiceCollection services, ICollection<Type> availableTypes)
{
services.Configure<TvdbOption>(_configuration.GetSection(TvdbOption.Path));
}
/// <inheritdoc />
public void ConfigureAspNet(IApplicationBuilder app)
{
ConfigurationManager.AddTyped<TvdbOption>(TvdbOption.Path);
}
}
}

View File

@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Controllers;
using Kyoo.Models;
using Kyoo.TheTvdb.Models;
using Microsoft.Extensions.Options;
using TvDbSharper;
using TvDbSharper.Dto;
namespace Kyoo.TheTvdb
{
/// <summary>
/// A metadata provider for The TVDB.
/// </summary>
public class ProviderTvdb : IMetadataProvider
{
/// <summary>
/// The internal tvdb client used to make requests.
/// </summary>
private readonly ITvDbClient _client;
/// <summary>
/// The API key used to authenticate with the tvdb API.
/// </summary>
private readonly IOptions<TvdbOption> _apiKey;
/// <inheritdoc />
public Provider Provider => new()
{
Slug = "the-tvdb",
Name = "TheTVDB",
LogoExtension = "png",
Logo = "https://www.thetvdb.com/images/logo.png"
};
/// <summary>
/// Create a new <see cref="ProviderTvdb"/> using a tvdb client and an api key.
/// </summary>
/// <param name="client">The tvdb client to use</param>
/// <param name="apiKey">The api key</param>
public ProviderTvdb(ITvDbClient client, IOptions<TvdbOption> apiKey)
{
_client = client;
_apiKey = apiKey;
}
/// <summary>
/// Authenticate and refresh the token of the tvdb client.
/// </summary>
private Task _Authenticate()
{
if (_client.Authentication.Token == null)
return _client.Authentication.AuthenticateAsync(_apiKey.Value.ApiKey);
return _client.Authentication.RefreshTokenAsync();
}
/// <inheritdoc />
public async Task<T> Get<T>(T item)
where T : class, IResource
{
await _Authenticate();
return item switch
{
Show show => await _GetShow(show) as T,
Episode episode => await _GetEpisode(episode) as T,
_ => null
};
}
/// <summary>
/// Retrieve metadata about a show.
/// </summary>
/// <param name="show">The base show to retrieve metadata for.</param>
/// <returns>A new show filled with metadata from the tvdb.</returns>
[ItemCanBeNull]
private async Task<Show> _GetShow([NotNull] Show show)
{
if (show.IsMovie)
return null;
if (!int.TryParse(show.GetID(Provider.Slug), out int id))
{
Show found = (await _SearchShow(show.Title)).FirstOrDefault();
if (found == null)
return null;
return await Get(found);
}
TvDbResponse<Series> series = await _client.Series.GetAsync(id);
Show ret = series.Data.ToShow(Provider);
TvDbResponse<Actor[]> people = await _client.Series.GetActorsAsync(id);
ret.People = people.Data.Select(x => x.ToPeopleRole(Provider)).ToArray();
return ret;
}
/// <summary>
/// Retrieve metadata about an episode.
/// </summary>
/// <param name="episode">The base episode to retrieve metadata for.</param>
/// <returns>A new episode filled with metadata from the tvdb.</returns>
[ItemCanBeNull]
private async Task<Episode> _GetEpisode([NotNull] Episode episode)
{
if (!int.TryParse(episode.Show?.GetID(Provider.Slug), out int id))
return null;
EpisodeQuery query = episode.AbsoluteNumber != null
? new EpisodeQuery {AbsoluteNumber = episode.AbsoluteNumber}
: new EpisodeQuery {AiredSeason = episode.SeasonNumber, AiredEpisode = episode.EpisodeNumber};
TvDbResponse<EpisodeRecord[]> episodes = await _client.Series.GetEpisodesAsync(id, 0, query);
return episodes.Data.FirstOrDefault()?.ToEpisode(Provider);
}
/// <inheritdoc />
public async Task<ICollection<T>> Search<T>(string query)
where T : class, IResource
{
await _Authenticate();
if (typeof(T) == typeof(Show))
return (await _SearchShow(query) as ICollection<T>)!;
return ArraySegment<T>.Empty;
}
/// <summary>
/// Search for shows in the tvdb.
/// </summary>
/// <param name="query">The query to ask the tvdb about.</param>
/// <returns>A list of shows that could be found on the tvdb.</returns>
[ItemNotNull]
private async Task<ICollection<Show>> _SearchShow(string query)
{
try
{
TvDbResponse<SeriesSearchResult[]> shows = await _client.Search.SearchSeriesByNameAsync(query);
return shows.Data.Select(x => x.ToShow(Provider)).ToArray();
}
catch (TvDbServerException)
{
return ArraySegment<Show>.Empty;
}
}
}
}

View File

@ -0,0 +1,18 @@
namespace Kyoo.TheTvdb.Models
{
/// <summary>
/// The option containing the api key for the tvdb.
/// </summary>
public class TvdbOption
{
/// <summary>
/// The path to get this option from the root configuration.
/// </summary>
public const string Path = "tvdb";
/// <summary>
/// The api key of the tvdb.
/// </summary>
public string ApiKey { get; set; }
}
}

@ -1 +1 @@
Subproject commit 22a02671918201d6d9d4e80a76f01b59b216a82d
Subproject commit c037270d3339fcf0075984a089f353c5c332a751

View File

@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Authentication", "Kyoo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.SqLite", "Kyoo.SqLite\Kyoo.SqLite.csproj", "{6515380E-1E57-42DA-B6E3-E1C8A848818A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.TheTvdb", "Kyoo.TheTvdb\Kyoo.TheTvdb.csproj", "{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -47,5 +49,9 @@ Global
{6515380E-1E57-42DA-B6E3-E1C8A848818A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6515380E-1E57-42DA-B6E3-E1C8A848818A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6515380E-1E57-42DA-B6E3-E1C8A848818A}.Release|Any CPU.Build.0 = Release|Any CPU
{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -36,7 +36,29 @@ namespace Kyoo.Controllers
_references = references.ToDictionary(x => x.Path, x => x.Type, StringComparer.OrdinalIgnoreCase);
}
private Type GetType(string path)
/// <inheritdoc />
public void AddTyped<T>(string path)
{
foreach (ConfigurationReference confRef in ConfigurationReference.CreateReference<T>(path))
_references.Add(confRef.Path, confRef.Type);
}
/// <inheritdoc />
public void AddUntyped(string path)
{
ConfigurationReference config = ConfigurationReference.CreateUntyped(path);
_references.Add(config.Path, config.Type);
}
/// <summary>
/// Get the type of the resource at the given path
/// </summary>
/// <param name="path">The path of the resource</param>
/// <exception cref="ArgumentException">The path is not editable or readable</exception>
/// <exception cref="ItemNotFoundException">No configuration exists for the given path</exception>
/// <returns>The type of the resource at the given path</returns>
private Type _GetType(string path)
{
path = path.Replace("__", ":");
@ -59,7 +81,7 @@ namespace Kyoo.Controllers
{
path = path.Replace("__", ":");
// TODO handle lists and dictionaries.
Type type = GetType(path);
Type type = _GetType(path);
object ret = _configuration.GetValue(type, path);
if (ret != null)
return ret;
@ -73,7 +95,7 @@ namespace Kyoo.Controllers
{
path = path.Replace("__", ":");
// TODO handle lists and dictionaries.
Type type = GetType(path);
Type type = _GetType(path);
if (typeof(T).IsAssignableFrom(type))
throw new InvalidCastException($"The type {typeof(T).Name} is not valid for " +
$"a resource of type {type.Name}.");
@ -84,12 +106,12 @@ namespace Kyoo.Controllers
public async Task EditValue(string path, object value)
{
path = path.Replace("__", ":");
Type type = GetType(path);
Type type = _GetType(path);
value = JObject.FromObject(value).ToObject(type);
if (value == null)
throw new ArgumentException("Invalid value format.");
ExpandoObject config = ToObject(_configuration);
ExpandoObject config = _ToObject(_configuration);
IDictionary<string, object> configDic = config;
configDic[path] = value;
JObject obj = JObject.FromObject(config);
@ -104,7 +126,7 @@ namespace Kyoo.Controllers
/// <param name="config">The configuration to transform</param>
/// <returns>A strongly typed representation of the configuration.</returns>
[SuppressMessage("ReSharper", "RedundantJumpStatement")]
private ExpandoObject ToObject(IConfiguration config)
private ExpandoObject _ToObject(IConfiguration config)
{
ExpandoObject obj = new();
@ -112,12 +134,12 @@ namespace Kyoo.Controllers
{
try
{
Type type = GetType(section.Path);
Type type = _GetType(section.Path);
obj.TryAdd(section.Key, section.Get(type));
}
catch (ArgumentException)
{
obj.TryAdd(section.Key, ToUntyped(section));
obj.TryAdd(section.Key, _ToUntyped(section));
}
catch
{
@ -133,13 +155,13 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="config">The section to convert</param>
/// <returns>The converted section</returns>
private static object ToUntyped(IConfigurationSection config)
private static object _ToUntyped(IConfigurationSection config)
{
ExpandoObject obj = new();
foreach (IConfigurationSection section in config.GetChildren())
{
obj.TryAdd(section.Key, ToUntyped(section));
obj.TryAdd(section.Key, _ToUntyped(section));
}
if (!obj.Any())

View File

@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Autofac.Features.Metadata;
using JetBrains.Annotations;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models;
using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Controllers
{
/// <summary>
/// A composite that merge every <see cref="IFileSystem"/> available
/// using <see cref="FileSystemMetadataAttribute"/>.
/// </summary>
public class FileSystemComposite : IFileSystem
{
/// <summary>
/// The list of <see cref="IFileSystem"/> mapped to their metadata.
/// </summary>
private readonly ICollection<Meta<Func<IFileSystem>, FileSystemMetadataAttribute>> _fileSystems;
/// <summary>
/// Create a new <see cref="FileSystemComposite"/> from a list of <see cref="IFileSystem"/> mapped to their
/// metadata.
/// </summary>
/// <param name="fileSystems">The list of filesystem mapped to their metadata.</param>
public FileSystemComposite(ICollection<Meta<Func<IFileSystem>, FileSystemMetadataAttribute>> fileSystems)
{
_fileSystems = fileSystems;
}
/// <summary>
/// Retrieve the file system that should be used for a given path.
/// </summary>
/// <param name="path">
/// The path that was requested.
/// </param>
/// <param name="usablePath">
/// The path that the returned file system wants
/// (respecting <see cref="FileSystemMetadataAttribute.StripScheme"/>).
/// </param>
/// <exception cref="ArgumentException">No file system was registered for the given path.</exception>
/// <returns>The file system that should be used for a given path</returns>
[NotNull]
private IFileSystem _GetFileSystemForPath([NotNull] string path, [NotNull] out string usablePath)
{
Regex schemeMatcher = new(@"(.+)://(.*)", RegexOptions.Compiled);
Match match = schemeMatcher.Match(path);
if (!match.Success)
{
usablePath = path;
Meta<Func<IFileSystem>, FileSystemMetadataAttribute> defaultFs = _fileSystems
.SingleOrDefault(x => x.Metadata.Scheme.Contains(""));
if (defaultFs == null)
throw new ArgumentException($"No file system registered for the default scheme.");
return defaultFs.Value.Invoke();
}
string scheme = match.Groups[1].Value;
Meta<Func<IFileSystem>, FileSystemMetadataAttribute> ret = _fileSystems
.SingleOrDefault(x => x.Metadata.Scheme.Contains(scheme));
if (ret == null)
throw new ArgumentException($"No file system registered for the scheme: {scheme}.");
usablePath = ret.Metadata.StripScheme ? match.Groups[2].Value : path;
return ret.Value.Invoke();
}
/// <inheritdoc />
public IActionResult FileResult(string path, bool rangeSupport = false, string type = null)
{
if (path == null)
return new NotFoundResult();
return _GetFileSystemForPath(path, out string relativePath)
.FileResult(relativePath, rangeSupport, type);
}
/// <inheritdoc />
public Task<Stream> GetReader(string path)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return _GetFileSystemForPath(path, out string relativePath)
.GetReader(relativePath);
}
/// <inheritdoc />
public Task<Stream> NewFile(string path)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return _GetFileSystemForPath(path, out string relativePath)
.NewFile(relativePath);
}
/// <inheritdoc />
public Task<string> CreateDirectory(string path)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return _GetFileSystemForPath(path, out string relativePath)
.CreateDirectory(relativePath);
}
/// <inheritdoc />
public string Combine(params string[] paths)
{
return _GetFileSystemForPath(paths[0], out string relativePath)
.Combine(paths[1..].Prepend(relativePath).ToArray());
}
/// <inheritdoc />
public Task<ICollection<string>> ListFiles(string path, SearchOption options = SearchOption.TopDirectoryOnly)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return _GetFileSystemForPath(path, out string relativePath)
.ListFiles(relativePath, options);
}
/// <inheritdoc />
public Task<bool> Exists(string path)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return _GetFileSystemForPath(path, out string relativePath)
.Exists(relativePath);
}
/// <inheritdoc />
public string GetExtraDirectory(Show show)
{
if (show == null)
throw new ArgumentNullException(nameof(show));
return _GetFileSystemForPath(show.Path, out string _)
.GetExtraDirectory(show);
}
}
}

View File

@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models;
using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Controllers
{
/// <summary>
/// A <see cref="IFileSystem"/> for http/https links.
/// </summary>
[FileSystemMetadata(new [] {"http", "https"})]
public class HttpFileSystem : IFileSystem
{
/// <summary>
/// The http client factory used to create clients.
/// </summary>
private readonly IHttpClientFactory _clientFactory;
/// <summary>
/// Create a <see cref="HttpFileSystem"/> using the given client factory.
/// </summary>
/// <param name="factory">The http client factory used to create clients.</param>
public HttpFileSystem(IHttpClientFactory factory)
{
_clientFactory = factory;
}
/// <inheritdoc />
public IActionResult FileResult(string path, bool rangeSupport = false, string type = null)
{
if (path == null)
return new NotFoundResult();
return new HttpForwardResult(new Uri(path), rangeSupport, type);
}
/// <inheritdoc />
public Task<Stream> GetReader(string path)
{
HttpClient client = _clientFactory.CreateClient();
return client.GetStreamAsync(path);
}
/// <inheritdoc />
public Task<Stream> NewFile(string path)
{
throw new NotSupportedException("An http filesystem is readonly, a new file can't be created.");
}
/// <inheritdoc />
public Task<string> CreateDirectory(string path)
{
throw new NotSupportedException("An http filesystem is readonly, a directory can't be created.");
}
/// <inheritdoc />
public string Combine(params string[] paths)
{
return Path.Combine(paths);
}
/// <inheritdoc />
public Task<ICollection<string>> ListFiles(string path, SearchOption options = SearchOption.TopDirectoryOnly)
{
throw new NotSupportedException("Listing files is not supported on an http filesystem.");
}
/// <inheritdoc />
public Task<bool> Exists(string path)
{
throw new NotSupportedException("Checking if a file exists is not supported on an http filesystem.");
}
/// <inheritdoc />
public string GetExtraDirectory(Show show)
{
throw new NotSupportedException("Extras can not be stored inside an http filesystem.");
}
}
/// <summary>
/// An <see cref="IActionResult"/> to proxy an http request.
/// </summary>
public class HttpForwardResult : IActionResult
{
/// <summary>
/// The path of the request to forward.
/// </summary>
private readonly Uri _path;
/// <summary>
/// Should the proxied result support ranges requests?
/// </summary>
private readonly bool _rangeSupport;
/// <summary>
/// If not null, override the content type of the resulting request.
/// </summary>
private readonly string _type;
/// <summary>
/// Create a new <see cref="HttpForwardResult"/>.
/// </summary>
/// <param name="path">The path of the request to forward.</param>
/// <param name="rangeSupport">Should the proxied result support ranges requests?</param>
/// <param name="type">If not null, override the content type of the resulting request.</param>
public HttpForwardResult(Uri path, bool rangeSupport, string type = null)
{
_path = path;
_rangeSupport = rangeSupport;
_type = type;
}
/// <inheritdoc />
public Task ExecuteResultAsync(ActionContext context)
{
// TODO implement that, example: https://github.com/twitchax/AspNetCore.Proxy/blob/14dd0f212d7abb43ca1bf8c890d5efb95db66acb/src/Core/Extensions/Http.cs#L15
throw new NotImplementedException();
}
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
@ -9,9 +10,10 @@ using Microsoft.AspNetCore.StaticFiles;
namespace Kyoo.Controllers
{
/// <summary>
/// A <see cref="IFileManager"/> for the local filesystem (using System.IO).
/// A <see cref="IFileSystem"/> for the local filesystem (using System.IO).
/// </summary>
public class FileManager : IFileManager
[FileSystemMetadata(new [] {"", "file"}, StripScheme = true)]
public class LocalFileSystem : IFileSystem
{
/// <summary>
/// An extension provider to get content types from files extensions.
@ -41,7 +43,7 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public IActionResult FileResult(string path, bool range = false, string type = null)
public IActionResult FileResult(string path, bool rangeSupport = false, string type = null)
{
if (path == null)
return new NotFoundResult();
@ -49,40 +51,56 @@ namespace Kyoo.Controllers
return new NotFoundResult();
return new PhysicalFileResult(Path.GetFullPath(path), type ?? _GetContentType(path))
{
EnableRangeProcessing = range
EnableRangeProcessing = rangeSupport
};
}
/// <inheritdoc />
public Stream GetReader(string path)
public Task<Stream> GetReader(string path)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return File.OpenRead(path);
return Task.FromResult<Stream>(File.OpenRead(path));
}
/// <inheritdoc />
public Stream NewFile(string path)
public Task<Stream> NewFile(string path)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return File.Create(path);
return Task.FromResult<Stream>(File.Create(path));
}
/// <inheritdoc />
public Task<string> CreateDirectory(string path)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
Directory.CreateDirectory(path);
return Task.FromResult(path);
}
/// <inheritdoc />
public Task<ICollection<string>> ListFiles(string path)
public string Combine(params string[] paths)
{
return Path.Combine(paths);
}
/// <inheritdoc />
public Task<ICollection<string>> ListFiles(string path, SearchOption options = SearchOption.TopDirectoryOnly)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return Task.FromResult<ICollection<string>>(Directory.Exists(path)
? Directory.GetFiles(path)
: Array.Empty<string>());
string[] ret = Directory.Exists(path)
? Directory.GetFiles(path, "*", options)
: Array.Empty<string>();
return Task.FromResult<ICollection<string>>(ret);
}
/// <inheritdoc />
public Task<bool> Exists(string path)
{
return Task.FromResult(File.Exists(path));
return Task.FromResult(File.Exists(path) || Directory.Exists(path));
}
/// <inheritdoc />
@ -92,24 +110,5 @@ namespace Kyoo.Controllers
Directory.CreateDirectory(path);
return path;
}
/// <inheritdoc />
public string GetExtraDirectory(Season season)
{
if (season.Show == null)
throw new NotImplementedException("Can't get season's extra directory when season.Show == null.");
// TODO use a season.Path here.
string path = Path.Combine(season.Show.Path, "Extra");
Directory.CreateDirectory(path);
return path;
}
/// <inheritdoc />
public string GetExtraDirectory(Episode episode)
{
string path = Path.Combine(Path.GetDirectoryName(episode.Path)!, "Extra");
Directory.CreateDirectory(path);
return path;
}
}
}

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using Autofac;
using Kyoo.Models.Options;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
@ -21,7 +22,7 @@ namespace Kyoo.Controllers
/// <summary>
/// The service provider. It allow plugin's activation.
/// </summary>
private readonly IServiceProvider _provider;
private IServiceProvider _provider;
/// <summary>
/// The configuration to get the plugin's directory.
/// </summary>
@ -51,6 +52,13 @@ namespace Kyoo.Controllers
_logger = logger;
}
public void SetProvider(IServiceProvider provider)
{
// TODO temporary bullshit to inject services before the configure asp net.
// TODO should rework this when the host will be reworked, as well as the asp net configure.
_provider = provider;
}
/// <inheritdoc />
public T GetPlugin<T>(string name)
@ -126,6 +134,13 @@ namespace Kyoo.Controllers
else
_logger.LogInformation("Plugin enabled: {Plugins}", _plugins.Select(x => x.Name));
}
/// <inheritdoc />
public void ConfigureContainer(ContainerBuilder builder)
{
foreach (IPlugin plugin in _plugins)
plugin.Configure(builder);
}
/// <inheritdoc />
public void ConfigureServices(IServiceCollection services)
@ -139,7 +154,12 @@ namespace Kyoo.Controllers
public void ConfigureAspnet(IApplicationBuilder app)
{
foreach (IPlugin plugin in _plugins)
{
using IServiceScope scope = _provider.CreateScope();
Helper.InjectServices(plugin, x => scope.ServiceProvider.GetRequiredService(x));
plugin.ConfigureAspNet(app);
Helper.InjectServices(plugin, _ => null);
}
}
/// <summary>

View File

@ -0,0 +1,103 @@
using System;
using Kyoo.Models;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Kyoo.Controllers
{
/// <summary>
/// A metadata provider composite that merge results from all available providers.
/// </summary>
public class ProviderComposite : AProviderComposite
{
/// <summary>
/// The list of metadata providers
/// </summary>
private readonly ICollection<IMetadataProvider> _providers;
/// <summary>
/// The list of selected providers. If no provider has been selected, this is null.
/// </summary>
private ICollection<Provider> _selectedProviders;
/// <summary>
/// The logger used to print errors.
/// </summary>
private readonly ILogger<ProviderComposite> _logger;
/// <summary>
/// Create a new <see cref="ProviderComposite"/> with a list of available providers.
/// </summary>
/// <param name="providers">The list of providers to merge.</param>
/// <param name="logger">The logger used to print errors.</param>
public ProviderComposite(IEnumerable<IMetadataProvider> providers, ILogger<ProviderComposite> logger)
{
_providers = providers.ToArray();
_logger = logger;
}
/// <inheritdoc />
public override void UseProviders(IEnumerable<Provider> providers)
{
_selectedProviders = providers.ToArray();
}
/// <summary>
/// Return the list of providers that should be used for queries.
/// </summary>
/// <returns>The list of providers to use, respecting the <see cref="UseProviders"/>.</returns>
private IEnumerable<IMetadataProvider> _GetProviders()
{
return _selectedProviders?
.Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug))
.Where(x => x != null)
?? _providers;
}
/// <inheritdoc />
public override async Task<T> Get<T>(T item)
{
T ret = item;
foreach (IMetadataProvider provider in _GetProviders())
{
try
{
ret = Merger.Merge(ret, await provider.Get(ret));
}
catch (Exception ex)
{
_logger.LogError(ex, "The provider {Provider} could not get a {Type}",
provider.Provider.Name, typeof(T).Name);
}
}
return ret;
}
/// <inheritdoc />
public override async Task<ICollection<T>> Search<T>(string query)
{
List<T> ret = new();
foreach (IMetadataProvider provider in _GetProviders())
{
try
{
ret.AddRange(await provider.Search<T>(query));
}
catch (Exception ex)
{
_logger.LogError(ex, "The provider {Provider} could not search for {Type}",
provider.Provider.Name, typeof(T).Name);
}
}
return ret;
}
}
}

View File

@ -1,166 +0,0 @@
using System;
using Kyoo.Models;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Kyoo.Controllers
{
public class ProviderManager : IProviderManager
{
private readonly IEnumerable<IMetadataProvider> _providers;
public ProviderManager(IPluginManager pluginManager)
{
_providers = pluginManager.GetPlugins<IMetadataProvider>();
}
private async Task<T> GetMetadata<T>(Func<IMetadataProvider, Task<T>> providerCall, Library library, string what)
where T : new()
{
T ret = new();
IEnumerable<IMetadataProvider> providers = library?.Providers
.Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug))
.Where(x => x != null)
?? _providers;
foreach (IMetadataProvider provider in providers)
{
try
{
ret = Merger.Merge(ret, await providerCall(provider));
} catch (Exception ex)
{
await Console.Error.WriteLineAsync(
$"The provider {provider.Provider.Name} could not work for {what}. Exception: {ex.Message}");
}
}
return ret;
}
private async Task<List<T>> GetMetadata<T>(
Func<IMetadataProvider, Task<ICollection<T>>> providerCall,
Library library,
string what)
{
List<T> ret = new();
IEnumerable<IMetadataProvider> providers = library?.Providers
.Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug))
.Where(x => x != null)
?? _providers;
foreach (IMetadataProvider provider in providers)
{
try
{
ret.AddRange(await providerCall(provider) ?? new List<T>());
} catch (Exception ex)
{
await Console.Error.WriteLineAsync(
$"The provider {provider.Provider.Name} coudln't work for {what}. Exception: {ex.Message}");
}
}
return ret;
}
public async Task<Collection> GetCollectionFromName(string name, Library library)
{
Collection collection = await GetMetadata(
provider => provider.GetCollectionFromName(name),
library,
$"the collection {name}");
collection.Name ??= name;
collection.Slug ??= Utility.ToSlug(name);
return collection;
}
public async Task<Show> CompleteShow(Show show, Library library)
{
return await GetMetadata(provider => provider.GetShowByID(show), library, $"the show {show.Title}");
}
public async Task<Show> SearchShow(string showName, bool isMovie, Library library)
{
Show show = await GetMetadata(async provider =>
{
Show searchResult = (await provider.SearchShows(showName, isMovie))?.FirstOrDefault();
if (searchResult == null)
return null;
return await provider.GetShowByID(searchResult);
}, library, $"the show {showName}");
show.Slug = Utility.ToSlug(showName);
show.Title ??= showName;
show.IsMovie = isMovie;
show.Genres = show.Genres?.GroupBy(x => x.Slug).Select(x => x.First()).ToList();
show.People = show.People?.GroupBy(x => x.Slug).Select(x => x.First()).ToList();
return show;
}
public async Task<IEnumerable<Show>> SearchShows(string showName, bool isMovie, Library library)
{
IEnumerable<Show> shows = await GetMetadata(
provider => provider.SearchShows(showName, isMovie),
library,
$"the show {showName}");
return shows.Select(show =>
{
show.Slug = Utility.ToSlug(showName);
show.Title ??= showName;
show.IsMovie = isMovie;
return show;
});
}
public async Task<Season> GetSeason(Show show, int seasonNumber, Library library)
{
Season season = await GetMetadata(
provider => provider.GetSeason(show, seasonNumber),
library,
$"the season {seasonNumber} of {show.Title}");
season.Show = show;
season.ShowID = show.ID;
season.ShowSlug = show.Slug;
season.Title ??= $"Season {season.SeasonNumber}";
return season;
}
public async Task<Episode> GetEpisode(Show show,
string episodePath,
int? seasonNumber,
int? episodeNumber,
int? absoluteNumber,
Library library)
{
Episode episode = await GetMetadata(
provider => provider.GetEpisode(show, seasonNumber, episodeNumber, absoluteNumber),
library,
"an episode");
episode.Show = show;
episode.ShowID = show.ID;
episode.ShowSlug = show.Slug;
episode.Path = episodePath;
episode.SeasonNumber ??= seasonNumber;
episode.EpisodeNumber ??= episodeNumber;
episode.AbsoluteNumber ??= absoluteNumber;
return episode;
}
public async Task<ICollection<PeopleRole>> GetPeople(Show show, Library library)
{
List<PeopleRole> people = await GetMetadata(
provider => provider.GetPeople(show),
library,
$"a cast member of {show.Title}");
return people?.GroupBy(x => x.Slug)
.Select(x => x.First())
.Select(x =>
{
x.Show = show;
x.ShowID = show.ID;
return x;
}).ToList();
}
}
}

View File

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Kyoo.Models;
using Kyoo.Models.Exceptions;
using Kyoo.Models.Options;
using Kyoo.Models.Watch;
using Microsoft.Extensions.Options;
namespace Kyoo.Controllers
{
/// <summary>
/// An identifier that use a regex to extract basics metadata.
/// </summary>
public class RegexIdentifier : IIdentifier
{
/// <summary>
/// The configuration of kyoo to retrieve the identifier regex.
/// </summary>
private readonly IOptionsMonitor<MediaOptions> _configuration;
/// <summary>
/// The library manager used to retrieve libraries paths.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Create a new <see cref="RegexIdentifier"/>.
/// </summary>
/// <param name="configuration">The regex patterns to use.</param>
/// <param name="libraryManager">The library manager used to retrieve libraries paths.</param>
public RegexIdentifier(IOptionsMonitor<MediaOptions> configuration, ILibraryManager libraryManager)
{
_configuration = configuration;
_libraryManager = libraryManager;
}
/// <summary>
/// Retrieve the relative path of an episode or subtitle.
/// </summary>
/// <param name="path">The full path of the episode</param>
/// <returns>The path relative to the library root.</returns>
private async Task<string> _GetRelativePath(string path)
{
string libraryPath = (await _libraryManager.GetAll<Library>())
.SelectMany(x => x.Paths)
.Where(path.StartsWith)
.OrderByDescending(x => x.Length)
.FirstOrDefault();
return path[(libraryPath?.Length ?? 0)..];
}
/// <inheritdoc />
public async Task<(Collection, Show, Season, Episode)> Identify(string path)
{
string relativePath = await _GetRelativePath(path);
Match match = _configuration.CurrentValue.Regex
.Select(x => new Regex(x, RegexOptions.IgnoreCase | RegexOptions.Compiled))
.Select(x => x.Match(relativePath))
.FirstOrDefault(x => x.Success);
if (match == null)
throw new IdentificationFailedException($"The episode at {path} does not match the episode's regex.");
(Collection collection, Show show, Season season, Episode episode) ret = (
collection: new Collection
{
Slug = Utility.ToSlug(match.Groups["Collection"].Value),
Name = match.Groups["Collection"].Value
},
show: new Show
{
Slug = Utility.ToSlug(match.Groups["Show"].Value),
Title = match.Groups["Show"].Value,
Path = Path.GetDirectoryName(path),
StartAir = match.Groups["StartYear"].Success
? new DateTime(int.Parse(match.Groups["StartYear"].Value), 1, 1)
: null
},
season: null,
episode: new Episode
{
SeasonNumber = match.Groups["Season"].Success
? int.Parse(match.Groups["Season"].Value)
: null,
EpisodeNumber = match.Groups["Episode"].Success
? int.Parse(match.Groups["Episode"].Value)
: null,
AbsoluteNumber = match.Groups["Absolute"].Success
? int.Parse(match.Groups["Absolute"].Value)
: null,
Path = path
}
);
if (ret.episode.SeasonNumber.HasValue)
ret.season = new Season { SeasonNumber = ret.episode.SeasonNumber.Value };
if (ret.episode.SeasonNumber == null && ret.episode.EpisodeNumber == null
&& ret.episode.AbsoluteNumber == null)
{
ret.show.IsMovie = true;
ret.episode.Title = ret.show.Title;
}
return ret;
}
/// <inheritdoc />
public Task<Track> IdentifyTrack(string path)
{
Match match = _configuration.CurrentValue.SubtitleRegex
.Select(x => new Regex(x, RegexOptions.IgnoreCase | RegexOptions.Compiled))
.Select(x => x.Match(path))
.FirstOrDefault(x => x.Success);
if (match == null)
throw new IdentificationFailedException($"The subtitle at {path} does not match the subtitle's regex.");
string episodePath = match.Groups["Episode"].Value;
string extension = Path.GetExtension(path);
return Task.FromResult(new Track
{
Type = StreamType.Subtitle,
Language = match.Groups["Language"].Value,
IsDefault = match.Groups["Default"].Success,
IsForced = match.Groups["Forced"].Success,
Codec = FileExtensions.SubtitleExtensions.GetValueOrDefault(extension, extension[1..]),
IsExternal = true,
Path = path,
Episode = new Episode
{
Path = episodePath
}
});
}
}
}

View File

@ -114,9 +114,6 @@ namespace Kyoo.Controllers
obj.ExternalIDs.ForEach(x => _database.Entry(x).State = EntityState.Added);
await _database.SaveChangesAsync($"Trying to insert a duplicated episode (slug {obj.Slug} already exists).");
return await ValidateTracks(obj);
// TODO check if this is needed
// obj.Slug = await _database.Entry(obj).Property(x => x.Slug).
// return obj;
}
/// <inheritdoc />

View File

@ -81,17 +81,28 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public override Task<LibraryItem> Create(LibraryItem obj) => throw new InvalidOperationException();
public override Task<LibraryItem> Create(LibraryItem obj)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task<LibraryItem> CreateIfNotExists(LibraryItem obj) => throw new InvalidOperationException();
public override Task<LibraryItem> CreateIfNotExists(LibraryItem obj)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task<LibraryItem> Edit(LibraryItem obj, bool reset) => throw new InvalidOperationException();
public override Task<LibraryItem> Edit(LibraryItem obj, bool resetOld)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task Delete(int id) => throw new InvalidOperationException();
public override Task Delete(int id)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task Delete(string slug) => throw new InvalidOperationException();
public override Task Delete(string slug)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task Delete(LibraryItem obj) => throw new InvalidOperationException();
public override Task Delete(LibraryItem obj)
=> throw new InvalidOperationException();
/// <summary>
/// Get a basic queryable for a library with the right mapping from shows & collections.

View File

@ -30,7 +30,7 @@ namespace Kyoo.Controllers
/// Create a new <see cref="LibraryRepository"/> instance.
/// </summary>
/// <param name="database">The database handle</param>
/// <param name="providers">The providere repository</param>
/// <param name="providers">The provider repository</param>
public LibraryRepository(DatabaseContext database, IProviderRepository providers)
: base(database)
{
@ -53,8 +53,8 @@ namespace Kyoo.Controllers
public override async Task<Library> Create(Library obj)
{
await base.Create(obj);
obj.ProviderLinks = obj.Providers?.Select(x => Link.Create(obj, x)).ToList();
_database.Entry(obj).State = EntityState.Added;
obj.ProviderLinks.ForEach(x => _database.Entry(x).State = EntityState.Added);
await _database.SaveChangesAsync($"Trying to insert a duplicated library (slug {obj.Slug} already exists).");
return obj;
}
@ -63,6 +63,9 @@ namespace Kyoo.Controllers
protected override async Task Validate(Library resource)
{
await base.Validate(resource);
resource.ProviderLinks = resource.Providers?
.Select(x => Link.Create(resource, x))
.ToList();
await resource.ProviderLinks.ForEachAsync(async id =>
{
id.Second = await _providers.CreateIfNotExists(id.Second);

View File

@ -96,7 +96,12 @@ namespace Kyoo.Controllers
protected override async Task Validate(Season resource)
{
if (resource.ShowID <= 0)
throw new InvalidOperationException($"Can't store a season not related to any show (showID: {resource.ShowID}).");
{
if (resource.Show == null)
throw new InvalidOperationException(
$"Can't store a season not related to any show (showID: {resource.ShowID}).");
resource.ShowID = resource.Show.ID;
}
await base.Validate(resource);
await resource.ExternalIDs.ForEachAsync(async id =>

View File

@ -4,11 +4,12 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Autofac.Features.Metadata;
using Autofac.Features.OwnedInstances;
using JetBrains.Annotations;
using Kyoo.Models.Attributes;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models.Exceptions;
using Kyoo.Models.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -22,9 +23,52 @@ namespace Kyoo.Controllers
public class TaskManager : BackgroundService, ITaskManager
{
/// <summary>
/// The service provider used to activate
/// The class representing task under this <see cref="TaskManager"/> jurisdiction.
/// </summary>
private readonly IServiceProvider _provider;
private class ManagedTask
{
/// <summary>
/// The metadata for this task (the slug, and other useful information).
/// </summary>
public TaskMetadataAttribute Metadata { get; set; }
/// <summary>
/// The function used to create the task object.
/// </summary>
public Func<Owned<ITask>> Factory { get; init; }
/// <summary>
/// The next scheduled date for this task
/// </summary>
public DateTime ScheduledDate { get; set; }
}
/// <summary>
/// A class representing a task inside the <see cref="TaskManager._queuedTasks"/> list.
/// </summary>
private class QueuedTask
{
/// <summary>
/// The task currently queued.
/// </summary>
public ManagedTask Task { get; init; }
/// <summary>
/// The progress reporter that this task should use.
/// </summary>
public IProgress<float> ProgressReporter { get; init; }
/// <summary>
/// The arguments to give to run the task with.
/// </summary>
public Dictionary<string, object> Arguments { get; init; }
/// <summary>
/// A token informing the task that it should be cancelled or not.
/// </summary>
public CancellationToken? CancellationToken { get; init; }
}
/// <summary>
/// The configuration instance used to get schedule information
/// </summary>
@ -37,15 +81,15 @@ namespace Kyoo.Controllers
/// <summary>
/// The list of tasks and their next scheduled run.
/// </summary>
private readonly List<(ITask task, DateTime scheduledDate)> _tasks;
private readonly List<ManagedTask> _tasks;
/// <summary>
/// The queue of tasks that should be run as soon as possible.
/// </summary>
private readonly Queue<(ITask, Dictionary<string, object>)> _queuedTasks = new();
private readonly Queue<QueuedTask> _queuedTasks = new();
/// <summary>
/// The currently running task.
/// </summary>
private ITask _runningTask;
private (TaskMetadataAttribute, ITask)? _runningTask;
/// <summary>
/// The cancellation token used to cancel the running task when the runner should shutdown.
/// </summary>
@ -55,22 +99,24 @@ namespace Kyoo.Controllers
/// <summary>
/// Create a new <see cref="TaskManager"/>.
/// </summary>
/// <param name="tasks">The list of tasks to manage</param>
/// <param name="provider">The service provider to request services for tasks</param>
/// <param name="tasks">The list of tasks to manage with their metadata</param>
/// <param name="options">The configuration to load schedule information.</param>
/// <param name="logger">The logger.</param>
public TaskManager(IEnumerable<ITask> tasks,
IServiceProvider provider,
public TaskManager(IEnumerable<Meta<Func<Owned<ITask>>, TaskMetadataAttribute>> tasks,
IOptionsMonitor<TaskOptions> options,
ILogger<TaskManager> logger)
{
_provider = provider;
_options = options;
_logger = logger;
_tasks = tasks.Select(x => (x, GetNextTaskDate(x.Slug))).ToList();
_tasks = tasks.Select(x => new ManagedTask
{
Factory = x.Value,
Metadata = x.Metadata,
ScheduledDate = GetNextTaskDate(x.Metadata.Slug)
}).ToList();
if (_tasks.Any())
_logger.LogTrace("Task manager initiated with: {Tasks}", _tasks.Select(x => x.task.Name));
_logger.LogTrace("Task manager initiated with: {Tasks}", _tasks.Select(x => x.Metadata.Name));
else
_logger.LogInformation("Task manager initiated without any tasks");
}
@ -100,21 +146,26 @@ namespace Kyoo.Controllers
/// <param name="cancellationToken">A token to stop the runner</param>
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
EnqueueStartupTasks();
_EnqueueStartupTasks();
while (!cancellationToken.IsCancellationRequested)
{
if (_queuedTasks.Any())
{
(ITask task, Dictionary<string, object> arguments) = _queuedTasks.Dequeue();
_runningTask = task;
QueuedTask task = _queuedTasks.Dequeue();
try
{
await RunTask(task, arguments);
await _RunTask(task.Task, task.ProgressReporter, task.Arguments, task.CancellationToken);
}
catch (TaskFailedException ex)
{
_logger.LogWarning("The task \"{Task}\" failed: {Message}",
task.Task.Metadata.Name, ex.Message);
}
catch (Exception e)
{
_logger.LogError(e, "An unhandled exception occured while running the task {Task}", task.Name);
_logger.LogError(e, "An unhandled exception occured while running the task {Task}",
task.Task.Metadata.Name);
}
}
else
@ -129,93 +180,116 @@ namespace Kyoo.Controllers
/// Parse parameters, inject a task and run it.
/// </summary>
/// <param name="task">The task to run</param>
/// <param name="progress">A progress reporter to know the percentage of completion of the task.</param>
/// <param name="arguments">The arguments to pass to the function</param>
/// <exception cref="ArgumentException">There was an invalid argument or a required argument was not found.</exception>
private async Task RunTask(ITask task, Dictionary<string, object> arguments)
/// <param name="cancellationToken">An optional cancellation token that will be passed to the task.</param>
/// <exception cref="ArgumentException">
/// If the number of arguments is invalid, if an argument can't be converted or if the task finds the argument
/// invalid.
/// </exception>
private async Task _RunTask(ManagedTask task,
[NotNull] IProgress<float> progress,
Dictionary<string, object> arguments,
CancellationToken? cancellationToken = null)
{
_logger.LogInformation("Task starting: {Task}", task.Name);
ICollection<TaskParameter> all = task.GetParameters();
ICollection<string> invalids = arguments.Keys
.Where(x => all.Any(y => x != y.Name))
.ToArray();
if (invalids.Any())
using (_logger.BeginScope("Task: {Task}", task.Metadata.Name))
{
string invalidsStr = string.Join(", ", invalids);
throw new ArgumentException($"{invalidsStr} are invalid arguments for the task {task.Name}");
}
TaskParameters args = new(all
.Select(x =>
await using Owned<ITask> taskObj = task.Factory.Invoke();
ICollection<TaskParameter> all = taskObj.Value.GetParameters();
_runningTask = (task.Metadata, taskObj.Value);
ICollection<string> invalids = arguments.Keys
.Where(x => all.All(y => x != y.Name))
.ToArray();
if (invalids.Any())
{
object value = arguments
.FirstOrDefault(y => string.Equals(y.Key, x.Name, StringComparison.OrdinalIgnoreCase))
.Value;
if (value == null && x.IsRequired)
throw new ArgumentException($"The argument {x.Name} is required to run {task.Name}" +
" but it was not specified.");
return x.CreateValue(value ?? x.DefaultValue);
}));
throw new ArgumentException($"{string.Join(", ", invalids)} are " +
$"invalid arguments for the task {task.Metadata.Name}");
}
using IServiceScope scope = _provider.CreateScope();
InjectServices(task, x => scope.ServiceProvider.GetRequiredService(x));
await task.Run(args, _taskToken.Token);
InjectServices(task, _ => null);
_logger.LogInformation("Task finished: {Task}", task.Name);
TaskParameters args = new(all
.Select(x =>
{
object value = arguments
.FirstOrDefault(y => string.Equals(y.Key, x.Name, StringComparison.OrdinalIgnoreCase))
.Value;
if (value == null && x.IsRequired)
throw new ArgumentException($"The argument {x.Name} is required to run " +
$"{task.Metadata.Name} but it was not specified.");
return x.CreateValue(value ?? x.DefaultValue);
}));
_logger.LogInformation("Task starting: {Task} ({Parameters})",
task.Metadata.Name, args.ToDictionary(x => x.Name, x => x.As<object>()));
CancellationToken token = cancellationToken != null
? CancellationTokenSource.CreateLinkedTokenSource(_taskToken.Token, cancellationToken.Value).Token
: _taskToken.Token;
await taskObj.Value.Run(args, progress, token);
_logger.LogInformation("Task finished: {Task}", task.Metadata.Name);
_runningTask = null;
}
}
/// <summary>
/// Inject services into the <see cref="InjectedAttribute"/> marked properties of the given object.
/// </summary>
/// <param name="obj">The object to inject</param>
/// <param name="retrieve">The function used to retrieve services. (The function is called immediately)</param>
private static void InjectServices(ITask obj, [InstantHandle] Func<Type, object> retrieve)
{
IEnumerable<PropertyInfo> properties = obj.GetType().GetProperties()
.Where(x => x.GetCustomAttribute<InjectedAttribute>() != null)
.Where(x => x.CanWrite);
foreach (PropertyInfo property in properties)
property.SetValue(obj, retrieve(property.PropertyType));
}
/// <summary>
/// Start tasks that are scheduled for start.
/// </summary>
private void QueueScheduledTasks()
{
IEnumerable<string> tasksToQueue = _tasks.Where(x => x.scheduledDate <= DateTime.Now)
.Select(x => x.task.Slug);
IEnumerable<string> tasksToQueue = _tasks.Where(x => x.ScheduledDate <= DateTime.Now)
.Select(x => x.Metadata.Slug);
foreach (string task in tasksToQueue)
{
_logger.LogDebug("Queuing task scheduled for running: {Task}", task);
StartTask(task, new Dictionary<string, object>());
StartTask(task, new Progress<float>(), new Dictionary<string, object>());
}
}
/// <summary>
/// Queue startup tasks with respect to the priority rules.
/// </summary>
private void EnqueueStartupTasks()
private void _EnqueueStartupTasks()
{
IEnumerable<ITask> startupTasks = _tasks.Select(x => x.task)
.Where(x => x.RunOnStartup)
.OrderByDescending(x => x.Priority);
foreach (ITask task in startupTasks)
_queuedTasks.Enqueue((task, new Dictionary<string, object>()));
IEnumerable<string> startupTasks = _tasks
.Where(x => x.Metadata.RunOnStartup)
.OrderByDescending(x => x.Metadata.Priority)
.Select(x => x.Metadata.Slug);
foreach (string task in startupTasks)
StartTask(task, new Progress<float>(), new Dictionary<string, object>());
}
/// <inheritdoc />
public void StartTask(string taskSlug, Dictionary<string, object> arguments = null)
public void StartTask(string taskSlug,
IProgress<float> progress,
Dictionary<string, object> arguments = null,
CancellationToken? cancellationToken = null)
{
arguments ??= new Dictionary<string, object>();
int index = _tasks.FindIndex(x => x.task.Slug == taskSlug);
int index = _tasks.FindIndex(x => x.Metadata.Slug == taskSlug);
if (index == -1)
throw new ItemNotFoundException($"No task found with the slug {taskSlug}");
_queuedTasks.Enqueue((_tasks[index].task, arguments));
_tasks[index] = (_tasks[index].task, GetNextTaskDate(taskSlug));
_queuedTasks.Enqueue(new QueuedTask
{
Task = _tasks[index],
ProgressReporter = progress,
Arguments = arguments,
CancellationToken = cancellationToken
});
_tasks[index].ScheduledDate = GetNextTaskDate(taskSlug);
}
/// <inheritdoc />
public void StartTask<T>(IProgress<float> progress,
Dictionary<string, object> arguments = null,
CancellationToken? cancellationToken = null)
where T : ITask
{
TaskMetadataAttribute metadata = typeof(T).GetCustomAttribute<TaskMetadataAttribute>();
if (metadata == null)
throw new ArgumentException($"No metadata found on the given task (type: {typeof(T).Name}).");
StartTask(metadata.Slug, progress, arguments, cancellationToken);
}
/// <summary>
@ -231,15 +305,17 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public ICollection<ITask> GetRunningTasks()
public ICollection<(TaskMetadataAttribute, ITask)> GetRunningTasks()
{
return new[] {_runningTask};
return _runningTask == null
? ArraySegment<(TaskMetadataAttribute, ITask)>.Empty
: new[] { _runningTask.Value };
}
/// <inheritdoc />
public ICollection<ITask> GetAllTasks()
public ICollection<TaskMetadataAttribute> GetAllTasks()
{
return _tasks.Select(x => x.task).ToArray();
return _tasks.Select(x => x.Metadata).ToArray();
}
}
}

View File

@ -1,157 +1,292 @@
using Kyoo.Models;
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Models.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Kyoo.Controllers
{
/// <summary>
/// Download images and retrieve the path of those images for a resource.
/// </summary>
public class ThumbnailsManager : IThumbnailsManager
{
private readonly IFileManager _files;
/// <summary>
/// The file manager used to download the image if the file is distant
/// </summary>
private readonly IFileSystem _files;
/// <summary>
/// A logger to report errors.
/// </summary>
private readonly ILogger<ThumbnailsManager> _logger;
/// <summary>
/// The options containing the base path of people images and provider logos.
/// </summary>
private readonly IOptionsMonitor<BasicOptions> _options;
/// <summary>
/// A library manager used to load episode and seasons shows if they are not loaded.
/// </summary>
private readonly Lazy<ILibraryManager> _library;
public ThumbnailsManager(IFileManager files, IOptionsMonitor<BasicOptions> options)
/// <summary>
/// Create a new <see cref="ThumbnailsManager"/>.
/// </summary>
/// <param name="files">The file manager to use.</param>
/// <param name="logger">A logger to report errors</param>
/// <param name="options">The options to use.</param>
/// <param name="library">A library manager used to load shows if they are not loaded.</param>
public ThumbnailsManager(IFileSystem files,
ILogger<ThumbnailsManager> logger,
IOptionsMonitor<BasicOptions> options,
Lazy<ILibraryManager> library)
{
_files = files;
_logger = logger;
_options = options;
Directory.CreateDirectory(_options.CurrentValue.PeoplePath);
Directory.CreateDirectory(_options.CurrentValue.ProviderPath);
_library = library;
options.OnChange(x =>
{
_files.CreateDirectory(x.PeoplePath);
_files.CreateDirectory(x.ProviderPath);
});
}
private static async Task DownloadImage(string url, string localPath, string what)
/// <inheritdoc />
public Task<bool> DownloadImages<T>(T item, bool alwaysDownload = false)
where T : IResource
{
if (item == null)
throw new ArgumentNullException(nameof(item));
return item switch
{
Show show => _Validate(show, alwaysDownload),
Season season => _Validate(season, alwaysDownload),
Episode episode => _Validate(episode, alwaysDownload),
People people => _Validate(people, alwaysDownload),
Provider provider => _Validate(provider, alwaysDownload),
_ => Task.FromResult(false)
};
}
/// <summary>
/// An helper function to download an image using a <see cref="LocalFileSystem"/>.
/// </summary>
/// <param name="url">The distant url of the image</param>
/// <param name="localPath">The local path of the image</param>
/// <param name="what">What is currently downloaded (used for errors)</param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _DownloadImage(string url, string localPath, string what)
{
if (url == localPath)
return false;
try
{
using WebClient client = new();
await client.DownloadFileTaskAsync(new Uri(url), localPath);
await using Stream reader = await _files.GetReader(url);
await using Stream local = await _files.NewFile(localPath);
await reader.CopyToAsync(local);
return true;
}
catch (WebException exception)
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"{what} could not be downloaded. Error: {exception.Message}.");
_logger.LogError(ex, "{What} could not be downloaded", what);
return false;
}
}
public async Task Validate(Show show, bool alwaysDownload)
/// <summary>
/// Download images of a specified show.
/// </summary>
/// <param name="show">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Show show, bool alwaysDownload)
{
bool ret = false;
if (show.Poster != null)
{
string posterPath = await GetShowPoster(show);
if (alwaysDownload || !File.Exists(posterPath))
await DownloadImage(show.Poster, posterPath, $"The poster of {show.Title}");
string posterPath = await GetPoster(show);
if (alwaysDownload || !await _files.Exists(posterPath))
ret |= await _DownloadImage(show.Poster, posterPath, $"The poster of {show.Title}");
}
if (show.Logo != null)
{
string logoPath = await GetShowLogo(show);
if (alwaysDownload || !File.Exists(logoPath))
await DownloadImage(show.Logo, logoPath, $"The logo of {show.Title}");
string logoPath = await GetLogo(show);
if (alwaysDownload || !await _files.Exists(logoPath))
ret |= await _DownloadImage(show.Logo, logoPath, $"The logo of {show.Title}");
}
if (show.Backdrop != null)
{
string backdropPath = await GetShowBackdrop(show);
if (alwaysDownload || !File.Exists(backdropPath))
await DownloadImage(show.Backdrop, backdropPath, $"The backdrop of {show.Title}");
string backdropPath = await GetThumbnail(show);
if (alwaysDownload || !await _files.Exists(backdropPath))
ret |= await _DownloadImage(show.Backdrop, backdropPath, $"The backdrop of {show.Title}");
}
foreach (PeopleRole role in show.People)
await Validate(role.People, alwaysDownload);
return ret;
}
public async Task Validate([NotNull] People people, bool alwaysDownload)
/// <summary>
/// Download images of a specified person.
/// </summary>
/// <param name="people">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] People people, bool alwaysDownload)
{
if (people == null)
throw new ArgumentNullException(nameof(people));
if (people.Poster == null)
return;
string localPath = await GetPeoplePoster(people);
if (alwaysDownload || !File.Exists(localPath))
await DownloadImage(people.Poster, localPath, $"The profile picture of {people.Name}");
return false;
string localPath = await GetPoster(people);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(people.Poster, localPath, $"The profile picture of {people.Name}");
return false;
}
public async Task Validate(Season season, bool alwaysDownload)
/// <summary>
/// Download images of a specified season.
/// </summary>
/// <param name="season">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Season season, bool alwaysDownload)
{
if (season?.Show?.Path == null || season.Poster == null)
return;
if (season.Poster == null)
return false;
string localPath = await GetSeasonPoster(season);
if (alwaysDownload || !File.Exists(localPath))
await DownloadImage(season.Poster, localPath, $"The poster of {season.Show.Title}'s season {season.SeasonNumber}");
string localPath = await GetPoster(season);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(season.Poster, localPath, $"The poster of {season.Slug}");
return false;
}
public async Task Validate(Episode episode, bool alwaysDownload)
/// <summary>
/// Download images of a specified episode.
/// </summary>
/// <param name="episode">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Episode episode, bool alwaysDownload)
{
if (episode?.Path == null || episode.Thumb == null)
return;
if (episode.Thumb == null)
return false;
string localPath = await GetEpisodeThumb(episode);
if (alwaysDownload || !File.Exists(localPath))
await DownloadImage(episode.Thumb, localPath, $"The thumbnail of {episode.Slug}");
string localPath = await _GetEpisodeThumb(episode);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(episode.Thumb, localPath, $"The thumbnail of {episode.Slug}");
return false;
}
public async Task Validate(Provider provider, bool alwaysDownload)
/// <summary>
/// Download images of a specified provider.
/// </summary>
/// <param name="provider">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Provider provider, bool alwaysDownload)
{
if (provider.Logo == null)
return;
return false;
string localPath = await GetProviderLogo(provider);
if (alwaysDownload || !File.Exists(localPath))
await DownloadImage(provider.Logo, localPath, $"The logo of {provider.Slug}");
string localPath = await GetLogo(provider);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(provider.Logo, localPath, $"The logo of {provider.Slug}");
return false;
}
public Task<string> GetShowBackdrop(Show show)
/// <inheritdoc />
public Task<string> GetPoster<T>(T item)
where T : IResource
{
if (show?.Path == null)
throw new ArgumentNullException(nameof(show));
return Task.FromResult(Path.Combine(_files.GetExtraDirectory(show), "backdrop.jpg"));
if (item == null)
throw new ArgumentNullException(nameof(item));
return item switch
{
Show show => Task.FromResult(_files.Combine(_files.GetExtraDirectory(show), "poster.jpg")),
Season season => _GetSeasonPoster(season),
People actor => Task.FromResult(_files.Combine(_options.CurrentValue.PeoplePath, $"{actor.Slug}.jpg")),
_ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a poster.")
};
}
/// <summary>
/// Retrieve the path of a season's poster.
/// </summary>
/// <param name="season">The season to retrieve the poster from.</param>
/// <returns>The path of the season's poster.</returns>
private async Task<string> _GetSeasonPoster(Season season)
{
if (season.Show == null)
await _library.Value.Load(season, x => x.Show);
return _files.Combine(_files.GetExtraDirectory(season.Show), $"season-{season.SeasonNumber}.jpg");
}
public Task<string> GetShowLogo(Show show)
/// <inheritdoc />
public Task<string> GetThumbnail<T>(T item)
where T : IResource
{
if (show?.Path == null)
throw new ArgumentNullException(nameof(show));
return Task.FromResult(Path.Combine(_files.GetExtraDirectory(show), "logo.png"));
if (item == null)
throw new ArgumentNullException(nameof(item));
return item switch
{
Show show => Task.FromResult(_files.Combine(_files.GetExtraDirectory(show), "backdrop.jpg")),
Episode episode => _GetEpisodeThumb(episode),
_ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a thumbnail.")
};
}
public Task<string> GetShowPoster(Show show)
/// <summary>
/// Get the path for an episode's thumbnail.
/// </summary>
/// <param name="episode">The episode to retrieve the thumbnail from</param>
/// <returns>The path of the given episode's thumbnail.</returns>
private async Task<string> _GetEpisodeThumb(Episode episode)
{
if (show?.Path == null)
throw new ArgumentNullException(nameof(show));
return Task.FromResult(Path.Combine(_files.GetExtraDirectory(show), "poster.jpg"));
if (episode.Show == null)
await _library.Value.Load(episode, x => x.Show);
string dir = _files.Combine(_files.GetExtraDirectory(episode.Show), "Thumbnails");
await _files.CreateDirectory(dir);
return _files.Combine(dir, $"{Path.GetFileNameWithoutExtension(episode.Path)}.jpg");
}
public Task<string> GetSeasonPoster(Season season)
/// <inheritdoc />
public Task<string> GetLogo<T>(T item)
where T : IResource
{
if (season == null)
throw new ArgumentNullException(nameof(season));
return Task.FromResult(Path.Combine(_files.GetExtraDirectory(season), $"season-{season.SeasonNumber}.jpg"));
}
public Task<string> GetEpisodeThumb(Episode episode)
{
string dir = Path.Combine(_files.GetExtraDirectory(episode), "Thumbnails");
Directory.CreateDirectory(dir);
return Task.FromResult(Path.Combine(dir, $"{Path.GetFileNameWithoutExtension(episode.Path)}.jpg"));
}
public Task<string> GetPeoplePoster(People people)
{
if (people == null)
throw new ArgumentNullException(nameof(people));
string peoplePath = _options.CurrentValue.PeoplePath;
string thumbPath = Path.GetFullPath(Path.Combine(peoplePath, $"{people.Slug}.jpg"));
return Task.FromResult(thumbPath.StartsWith(peoplePath) ? thumbPath : null);
}
public Task<string> GetProviderLogo(Provider provider)
{
if (provider == null)
throw new ArgumentNullException(nameof(provider));
string providerPath = _options.CurrentValue.ProviderPath;
string thumbPath = Path.GetFullPath(Path.Combine(providerPath, $"{provider.Slug}.{provider.LogoExtension}"));
return Task.FromResult(thumbPath.StartsWith(providerPath) ? thumbPath : null);
if (item == null)
throw new ArgumentNullException(nameof(item));
return Task.FromResult(item switch
{
Show show => _files.Combine(_files.GetExtraDirectory(show), "logo.png"),
Provider provider => _files.Combine(_options.CurrentValue.ProviderPath,
$"{provider.Slug}.{provider.LogoExtension}"),
_ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a thumbnail.")
});
}
}
}

View File

@ -72,24 +72,29 @@ namespace Kyoo.Controllers
}
}
private readonly IFileManager _files;
private readonly IFileSystem _files;
private readonly IOptions<BasicOptions> _options;
private readonly Lazy<ILibraryManager> _library;
public Transcoder(IFileManager files, IOptions<BasicOptions> options)
public Transcoder(IFileSystem files, IOptions<BasicOptions> options, Lazy<ILibraryManager> library)
{
_files = files;
_options = options;
_library = library;
if (TranscoderAPI.init() != Marshal.SizeOf<Stream>())
throw new BadTranscoderException();
}
public Task<Track[]> ExtractInfos(Episode episode, bool reextract)
public async Task<Track[]> ExtractInfos(Episode episode, bool reextract)
{
string dir = _files.GetExtraDirectory(episode);
if (episode.Show == null)
await _library.Value.Load(episode, x => x.Show);
string dir = _files.GetExtraDirectory(episode.Show);
if (dir == null)
throw new ArgumentException("Invalid path.");
return Task.Factory.StartNew(
return await Task.Factory.StartNew(
() => TranscoderAPI.ExtractInfos(episode.Path, dir, reextract),
TaskCreationOptions.LongRunning);
}

View File

@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Autofac;
using Autofac.Core;
using Autofac.Core.Registration;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
using Kyoo.Models.Options;
using Kyoo.Models.Permissions;
using Kyoo.Tasks;
@ -31,12 +34,14 @@ namespace Kyoo
/// <inheritdoc />
public ICollection<Type> Provides => new[]
{
typeof(IFileManager),
typeof(IFileSystem),
typeof(ITranscoder),
typeof(IThumbnailsManager),
typeof(IProviderManager),
typeof(IMetadataProvider),
typeof(ITaskManager),
typeof(ILibraryManager)
typeof(ILibraryManager),
typeof(IIdentifier),
typeof(AProviderComposite)
};
/// <inheritdoc />
@ -77,6 +82,11 @@ namespace Kyoo
/// The configuration to use.
/// </summary>
private readonly IConfiguration _configuration;
/// <summary>
/// The configuration manager used to register typed/untyped implementations.
/// </summary>
[Injected] public IConfigurationManager ConfigurationManager { private get; set; }
/// <summary>
@ -88,20 +98,58 @@ namespace Kyoo
_configuration = configuration;
}
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder.RegisterComposite<FileSystemComposite, IFileSystem>();
builder.RegisterType<LocalFileSystem>().As<IFileSystem>().SingleInstance();
builder.RegisterType<HttpFileSystem>().As<IFileSystem>().SingleInstance();
builder.RegisterType<ConfigurationManager>().As<IConfigurationManager>().SingleInstance();
builder.RegisterType<Transcoder>().As<ITranscoder>().SingleInstance();
builder.RegisterType<ThumbnailsManager>().As<IThumbnailsManager>().SingleInstance();
builder.RegisterType<TaskManager>().As<ITaskManager>().SingleInstance();
builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope();
builder.RegisterType<RegexIdentifier>().As<IIdentifier>().SingleInstance();
builder.RegisterComposite<ProviderComposite, IMetadataProvider>();
builder.Register(x => (AProviderComposite)x.Resolve<IMetadataProvider>());
builder.RegisterTask<Crawler>();
builder.RegisterTask<Housekeeping>();
builder.RegisterTask<RegisterEpisode>();
builder.RegisterTask<RegisterSubtitle>();
builder.RegisterTask<MetadataProviderLoader>();
static bool DatabaseIsPresent(IComponentRegistryBuilder x)
=> x.IsRegistered(new TypedService(typeof(DatabaseContext)));
builder.RegisterRepository<ILibraryRepository, LibraryRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<ILibraryItemRepository, LibraryItemRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<ICollectionRepository, CollectionRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<IShowRepository, ShowRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<ISeasonRepository, SeasonRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<IEpisodeRepository, EpisodeRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<ITrackRepository, TrackRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<IPeopleRepository, PeopleRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<IStudioRepository, StudioRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<IGenreRepository, GenreRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<IProviderRepository, ProviderRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterRepository<IUserRepository, UserRepository>().OnlyIf(DatabaseIsPresent);
builder.RegisterType<PassthroughPermissionValidator>().As<IPermissionValidator>()
.IfNotRegistered(typeof(IPermissionValidator));
}
/// <inheritdoc />
public void Configure(IServiceCollection services, ICollection<Type> availableTypes)
{
string publicUrl = _configuration.GetPublicUrl();
services.Configure<BasicOptions>(_configuration.GetSection(BasicOptions.Path));
services.AddConfiguration<BasicOptions>(BasicOptions.Path);
services.Configure<TaskOptions>(_configuration.GetSection(TaskOptions.Path));
services.AddConfiguration<TaskOptions>(TaskOptions.Path);
services.Configure<MediaOptions>(_configuration.GetSection(MediaOptions.Path));
services.AddConfiguration<MediaOptions>(MediaOptions.Path);
services.AddUntypedConfiguration("database");
services.AddUntypedConfiguration("logging");
services.AddControllers()
.AddNewtonsoftJson(x =>
{
@ -109,41 +157,18 @@ namespace Kyoo
x.SerializerSettings.Converters.Add(new PeopleRoleConverter());
});
services.AddSingleton<IConfigurationManager, ConfigurationManager>();
services.AddSingleton<IFileManager, FileManager>();
services.AddSingleton<ITranscoder, Transcoder>();
services.AddSingleton<IThumbnailsManager, ThumbnailsManager>();
services.AddSingleton<IProviderManager, ProviderManager>();
services.AddSingleton<ITaskManager, TaskManager>();
services.AddHostedService(x => x.GetService<ITaskManager>() as TaskManager);
services.AddScoped<ILibraryManager, LibraryManager>();
if (ProviderCondition.Has(typeof(DatabaseContext), availableTypes))
{
services.AddRepository<ILibraryRepository, LibraryRepository>();
services.AddRepository<ILibraryItemRepository, LibraryItemRepository>();
services.AddRepository<ICollectionRepository, CollectionRepository>();
services.AddRepository<IShowRepository, ShowRepository>();
services.AddRepository<ISeasonRepository, SeasonRepository>();
services.AddRepository<IEpisodeRepository, EpisodeRepository>();
services.AddRepository<ITrackRepository, TrackRepository>();
services.AddRepository<IPeopleRepository, PeopleRepository>();
services.AddRepository<IStudioRepository, StudioRepository>();
services.AddRepository<IGenreRepository, GenreRepository>();
services.AddRepository<IProviderRepository, ProviderRepository>();
services.AddRepository<IUserRepository, UserRepository>();
}
services.AddTask<Crawler>();
if (services.All(x => x.ServiceType != typeof(IPermissionValidator)))
services.AddSingleton<IPermissionValidator, PassthroughPermissionValidator>();
}
/// <inheritdoc />
public void ConfigureAspNet(IApplicationBuilder app)
{
ConfigurationManager.AddTyped<BasicOptions>(BasicOptions.Path);
ConfigurationManager.AddTyped<TaskOptions>(TaskOptions.Path);
ConfigurationManager.AddTyped<MediaOptions>(MediaOptions.Path);
ConfigurationManager.AddUntyped("database");
ConfigurationManager.AddUntyped("logging");
FileExtensionContentTypeProvider contentTypeProvider = new();
contentTypeProvider.Mappings[".data"] = "application/octet-stream";
app.UseStaticFiles(new StaticFileOptions

47
Kyoo/Helper.cs Normal file
View File

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Models.Attributes;
using Newtonsoft.Json;
namespace Kyoo
{
public static class Helper
{
/// <summary>
/// Inject services into the <see cref="InjectedAttribute"/> marked properties of the given object.
/// </summary>
/// <param name="obj">The object to inject</param>
/// <param name="retrieve">The function used to retrieve services. (The function is called immediately)</param>
public static void InjectServices(object obj, [InstantHandle] Func<Type, object> retrieve)
{
IEnumerable<PropertyInfo> properties = obj.GetType().GetProperties()
.Where(x => x.GetCustomAttribute<InjectedAttribute>() != null)
.Where(x => x.CanWrite);
foreach (PropertyInfo property in properties)
property.SetValue(obj, retrieve(property.PropertyType));
}
/// <summary>
/// An helper method to get json content from an http server. This is a temporary thing and will probably be
/// replaced by a call to the function of the same name in the <c>System.Net.Http.Json</c> namespace when .net6
/// gets released.
/// </summary>
/// <param name="client">The http server to use.</param>
/// <param name="url">The url to retrieve</param>
/// <typeparam name="T">The type of object to convert</typeparam>
/// <returns>A T representing the json contained at the given url.</returns>
public static async Task<T> GetFromJsonAsync<T>(this HttpClient client, string url)
{
HttpResponseMessage ret = await client.GetAsync(url);
ret.EnsureSuccessStatusCode();
string content = await ret.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<T>(content);
}
}
}

View File

@ -32,8 +32,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Collections.Immutable" Version="5.0.0" />
<ProjectReference Include="../Kyoo.Common/Kyoo.Common.csproj" />
<ProjectReference Include="../Kyoo.CommonAPI/Kyoo.CommonAPI.csproj" />
<PackageReference Include="Autofac" Version="6.2.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="7.1.0" />
<PackageReference Include="Autofac.Extras.AttributeMetadata" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices" Version="3.1.17" />

View File

@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
namespace Kyoo.Models.Watch
{
/// <summary>
/// A static class allowing one to identify files extensions.
/// </summary>
public static class FileExtensions
{
/// <summary>
/// The list of known video extensions
/// </summary>
public static readonly ImmutableArray<string> VideoExtensions = ImmutableArray.Create(
".webm",
".mkv",
".flv",
".vob",
".ogg",
".ogv",
".avi",
".mts",
".m2ts",
".ts",
".mov",
".qt",
".asf",
".mp4",
".m4p",
".m4v",
".mpg",
".mp2",
".mpeg",
".mpe",
".mpv",
".m2v",
".3gp",
".3g2"
);
/// <summary>
/// Check if a file represent a video file (only by checking the extension of the file)
/// </summary>
/// <param name="filePath">The path of the file to check</param>
/// <returns><c>true</c> if the file is a video file, <c>false</c> otherwise.</returns>
public static bool IsVideo(string filePath)
{
return VideoExtensions.Contains(Path.GetExtension(filePath));
}
/// <summary>
/// The dictionary of known subtitles extensions and the name of the subtitle codec.
/// </summary>
public static readonly ImmutableDictionary<string, string> SubtitleExtensions = new Dictionary<string, string>
{
{".ass", "ass"},
{".str", "subrip"}
}.ToImmutableDictionary();
/// <summary>
/// Check if a file represent a subtitle file (only by checking the extension of the file)
/// </summary>
/// <param name="filePath">The path of the file to check</param>
/// <returns><c>true</c> if the file is a subtitle file, <c>false</c> otherwise.</returns>
public static bool IsSubtitle(string filePath)
{
return SubtitleExtensions.ContainsKey(Path.GetExtension(filePath));
}
}
}

View File

@ -1,12 +0,0 @@
using System;
using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Models
{
public class LazyDi<T> : Lazy<T>
{
public LazyDi(IServiceProvider provider)
: base(provider.GetRequiredService<T>)
{ }
}
}

View File

@ -13,11 +13,11 @@ namespace Kyoo.Models.Options
/// <summary>
/// A regex for episodes
/// </summary>
public string Regex { get; set; }
public string[] Regex { get; set; }
/// <summary>
/// A regex for subtitles
/// </summary>
public string SubtitleRegex { get; set; }
public string[] SubtitleRegex { get; set; }
}
}

View File

@ -2,12 +2,14 @@ using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading.Tasks;
using Autofac;
using Autofac.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Kyoo
{
/// <summary>
@ -30,6 +32,8 @@ namespace Kyoo
if (!File.Exists("./settings.json"))
File.Copy(Path.Join(AppDomain.CurrentDomain.BaseDirectory, "settings.json"), "settings.json");
IWebHostBuilder builder = CreateWebHostBuilder(args);
bool? debug = Environment.GetEnvironmentVariable("ENVIRONMENT")?.ToLowerInvariant() switch
{
"d" => true,
@ -43,18 +47,21 @@ namespace Kyoo
};
if (debug == null && Environment.GetEnvironmentVariable("ENVIRONMENT") != null)
Console.WriteLine($"Invalid ENVIRONMENT variable. Supported values are \"debug\" and \"prod\". Ignoring...");
{
Console.WriteLine(
$"Invalid ENVIRONMENT variable. Supported values are \"debug\" and \"prod\". Ignoring...");
}
#if DEBUG
debug ??= true;
#endif
Console.WriteLine($"Running as {Environment.UserName}.");
IWebHostBuilder builder = CreateWebHostBuilder(args);
if (debug != null)
builder = builder.UseEnvironment(debug == true ? "Development" : "Production");
try
{
Console.WriteLine($"Running as {Environment.UserName}.");
await builder.Build().RunAsync();
}
catch (Exception ex)
@ -86,6 +93,11 @@ namespace Kyoo
IConfiguration configuration = SetupConfig(new ConfigurationBuilder(), args).Build();
return new WebHostBuilder()
.ConfigureServices(x =>
{
AutofacServiceProviderFactory factory = new();
x.Replace(ServiceDescriptor.Singleton<IServiceProviderFactory<ContainerBuilder>>(factory));
})
.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
.UseConfiguration(configuration)
.ConfigureAppConfiguration(x => SetupConfig(x, args))
@ -99,12 +111,6 @@ namespace Kyoo
.AddDebug()
.AddEventSourceLogger();
})
.UseDefaultServiceProvider((context, options) =>
{
options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
if (context.HostingEnvironment.IsDevelopment())
StaticWebAssetsLoader.UseStaticWebAssets(context.HostingEnvironment, context.Configuration);
})
.ConfigureServices(x => x.AddRouting())
.UseKestrel(options => { options.AddServerHeader = false; })
.UseIIS()

View File

@ -1,8 +1,9 @@
using System;
using System.IO;
using Autofac;
using Autofac.Extras.AttributeMetadata;
using Kyoo.Authentication;
using Kyoo.Controllers;
using Kyoo.Models;
using Kyoo.Models.Options;
using Kyoo.Postgresql;
using Kyoo.Tasks;
@ -71,19 +72,23 @@ namespace Kyoo
services.AddHttpClient();
services.AddTransient(typeof(Lazy<>), typeof(LazyDi<>));
services.AddSingleton(_plugins);
services.AddTask<PluginInitializer>();
_plugins.ConfigureServices(services);
}
public void ConfigureContainer(ContainerBuilder builder)
{
builder.RegisterModule<AttributedMetadataModule>();
builder.RegisterInstance(_plugins).As<IPluginManager>().ExternallyOwned();
builder.RegisterTask<PluginInitializer>();
_plugins.ConfigureContainer(builder);
}
/// <summary>
/// Configure the asp net host.
/// </summary>
/// <param name="app">The asp net host to configure</param>
/// <param name="env">The host environment (is the app in development mode?)</param>
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider provider)
{
if (env.IsDevelopment())
app.UseDeveloperExceptionPage();
@ -110,7 +115,9 @@ namespace Kyoo
return next();
});
app.UseResponseCompression();
if (_plugins is PluginManager manager)
manager.SetProvider(provider);
_plugins.ConfigureAspnet(app);
app.UseSpa(spa =>

View File

@ -1,36 +1,60 @@
using System;
using Kyoo.Models;
using Microsoft.Extensions.Configuration;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Kyoo.Common.Models.Attributes;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
using Kyoo.Models.Exceptions;
using Microsoft.Extensions.DependencyInjection;
using Kyoo.Models.Watch;
using Microsoft.Extensions.Logging;
namespace Kyoo.Tasks
{
/// <summary>
/// A task to add new video files.
/// </summary>
[TaskMetadata("scan", "Scan libraries", "Scan your libraries and load data for new shows.", RunOnStartup = true)]
public class Crawler : ITask
{
public string Slug => "scan";
public string Name => "Scan libraries";
public string Description => "Scan your libraries, load data for new shows and remove shows that don't exist anymore.";
public string HelpMessage => "Reloading all libraries is a long process and may take up to 24 hours if it is the first scan in a while.";
public bool RunOnStartup => true;
public int Priority => 0;
[Injected] public IServiceProvider ServiceProvider { private get; set; }
[Injected] public IThumbnailsManager ThumbnailsManager { private get; set; }
[Injected] public IProviderManager MetadataProvider { private get; set; }
[Injected] public ITranscoder Transcoder { private get; set; }
[Injected] public IConfiguration Config { private get; set; }
/// <summary>
/// The library manager used to get libraries and providers to use.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// The file manager used walk inside directories and check they existences.
/// </summary>
private readonly IFileSystem _fileSystem;
/// <summary>
/// A task manager used to create sub tasks for each episode to add to the database.
/// </summary>
private readonly ITaskManager _taskManager;
/// <summary>
/// The logger used to inform the current status to the console.
/// </summary>
private readonly ILogger<Crawler> _logger;
private int _parallelTasks;
/// <summary>
/// Create a new <see cref="Crawler"/>.
/// </summary>
/// <param name="libraryManager">The library manager to retrieve existing episodes/library/tracks</param>
/// <param name="fileSystem">The file system to glob files</param>
/// <param name="taskManager">The task manager used to start <see cref="RegisterEpisode"/>.</param>
/// <param name="logger">The logger used print messages.</param>
public Crawler(ILibraryManager libraryManager,
IFileSystem fileSystem,
ITaskManager taskManager,
ILogger<Crawler> logger)
{
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_taskManager = taskManager;
_logger = logger;
}
/// <inheritdoc />
public TaskParameters GetParameters()
{
return new()
@ -39,397 +63,108 @@ namespace Kyoo.Tasks
};
}
public int? Progress()
/// <inheritdoc />
public async Task Run(TaskParameters arguments, IProgress<float> progress, CancellationToken cancellationToken)
{
// TODO implement this later.
return null;
}
public async Task Run(TaskParameters parameters,
CancellationToken cancellationToken)
{
string argument = parameters["slug"].As<string>();
_parallelTasks = Config.GetValue<int>("parallelTasks");
if (_parallelTasks <= 0)
_parallelTasks = 30;
using IServiceScope serviceScope = ServiceProvider.CreateScope();
ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService<ILibraryManager>();
foreach (Show show in await libraryManager!.GetAll<Show>())
if (!Directory.Exists(show.Path))
await libraryManager.Delete(show);
ICollection<Episode> episodes = await libraryManager.GetAll<Episode>();
foreach (Episode episode in episodes)
if (!File.Exists(episode.Path))
await libraryManager.Delete(episode);
ICollection<Track> tracks = await libraryManager.GetAll<Track>();
foreach (Track track in tracks)
if (!File.Exists(track.Path))
await libraryManager.Delete(track);
string argument = arguments["slug"].As<string>();
ICollection<Library> libraries = argument == null
? await libraryManager.GetAll<Library>()
: new [] { await libraryManager.GetOrDefault<Library>(argument)};
? await _libraryManager.GetAll<Library>()
: new [] { await _libraryManager.GetOrDefault<Library>(argument)};
if (argument != null && libraries.First() == null)
throw new ArgumentException($"No library found with the name {argument}");
foreach (Library library in libraries)
await libraryManager.Load(library, x => x.Providers);
await _libraryManager.Load(library, x => x.Providers);
progress.Report(0);
float percent = 0;
ICollection<Episode> episodes = await _libraryManager.GetAll<Episode>();
ICollection<Track> tracks = await _libraryManager.GetAll<Track>();
foreach (Library library in libraries)
await Scan(library, episodes, tracks, cancellationToken);
Console.WriteLine("Scan finished!");
{
IProgress<float> reporter = new Progress<float>(x =>
{
// ReSharper disable once AccessToModifiedClosure
progress.Report(percent + x / libraries.Count);
});
await Scan(library, episodes, tracks, reporter, cancellationToken);
percent += 100f / libraries.Count;
if (cancellationToken.IsCancellationRequested)
return;
}
progress.Report(100);
}
private async Task Scan(Library library, IEnumerable<Episode> episodes, IEnumerable<Track> tracks, CancellationToken cancellationToken)
private async Task Scan(Library library,
IEnumerable<Episode> episodes,
IEnumerable<Track> tracks,
IProgress<float> progress,
CancellationToken cancellationToken)
{
Console.WriteLine($"Scanning library {library.Name} at {string.Join(", ", library.Paths)}.");
_logger.LogInformation("Scanning library {Library} at {Paths}", library.Name, library.Paths);
foreach (string path in library.Paths)
{
if (cancellationToken.IsCancellationRequested)
continue;
ICollection<string> files = await _fileSystem.ListFiles(path, SearchOption.AllDirectories);
string[] files;
try
{
files = Directory.GetFiles(path, "*", SearchOption.AllDirectories);
}
catch (DirectoryNotFoundException)
{
await Console.Error.WriteLineAsync($"The library's directory {path} could not be found (library slug: {library.Slug})");
continue;
}
catch (PathTooLongException)
{
await Console.Error.WriteLineAsync($"The library's directory {path} is too long for this system. (library slug: {library.Slug})");
continue;
}
catch (ArgumentException)
{
await Console.Error.WriteLineAsync($"The library's directory {path} is invalid. (library slug: {library.Slug})");
continue;
}
catch (UnauthorizedAccessException ex)
{
await Console.Error.WriteLineAsync($"{ex.Message} (library slug: {library.Slug})");
continue;
}
if (cancellationToken.IsCancellationRequested)
return;
List<IGrouping<string, string>> shows = files
.Where(x => IsVideo(x) && episodes.All(y => y.Path != x))
// We try to group episodes by shows to register one episode of each show first.
// This speeds up the scan process because further episodes of a show are registered when all metadata
// of the show has already been fetched.
List<IGrouping<string, string>> shows = files
.Where(FileExtensions.IsVideo)
.Where(x => episodes.All(y => y.Path != x))
.GroupBy(Path.GetDirectoryName)
.ToList();
// TODO If the library's path end with a /, the regex is broken.
IEnumerable<string> tasks = shows.Select(x => x.First());
foreach (string[] showTasks in tasks.BatchBy(_parallelTasks))
await Task.WhenAll(showTasks
.Select(x => RegisterFile(x, x.Substring(path.Length), library, cancellationToken)));
tasks = shows.SelectMany(x => x.Skip(1));
foreach (string[] episodeTasks in tasks.BatchBy(_parallelTasks * 2))
await Task.WhenAll(episodeTasks
.Select(x => RegisterFile(x, x.Substring(path.Length), library, cancellationToken)));
await Task.WhenAll(files.Where(x => IsSubtitle(x) && tracks.All(y => y.Path != x))
.Select(x => RegisterExternalSubtitle(x, cancellationToken)));
}
}
private async Task RegisterExternalSubtitle(string path, CancellationToken token)
{
try
{
if (token.IsCancellationRequested || path.Split(Path.DirectorySeparatorChar).Contains("Subtitles"))
return;
using IServiceScope serviceScope = ServiceProvider.CreateScope();
ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService<ILibraryManager>();
string patern = Config.GetValue<string>("subtitleRegex");
Regex regex = new(patern, RegexOptions.IgnoreCase);
Match match = regex.Match(path);
if (!match.Success)
{
await Console.Error.WriteLineAsync($"The subtitle at {path} does not match the subtitle's regex.");
return;
}
string episodePath = match.Groups["Episode"].Value;
Episode episode = await libraryManager!.Get<Episode>(x => x.Path.StartsWith(episodePath));
Track track = new()
{
Type = StreamType.Subtitle,
Language = match.Groups["Language"].Value,
IsDefault = match.Groups["Default"].Value.Length > 0,
IsForced = match.Groups["Forced"].Value.Length > 0,
Codec = SubtitleExtensions[Path.GetExtension(path)],
IsExternal = true,
Path = path,
Episode = episode
};
await libraryManager.Create(track);
Console.WriteLine($"Registering subtitle at: {path}.");
}
catch (ItemNotFoundException)
{
await Console.Error.WriteLineAsync($"No episode found for subtitle at: ${path}.");
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"Unknown error while registering subtitle: {ex.Message}");
}
}
private async Task RegisterFile(string path, string relativePath, Library library, CancellationToken token)
{
if (token.IsCancellationRequested)
return;
try
{
using IServiceScope serviceScope = ServiceProvider.CreateScope();
ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService<ILibraryManager>();
string patern = Config.GetValue<string>("regex");
Regex regex = new(patern, RegexOptions.IgnoreCase);
Match match = regex.Match(relativePath);
if (!match.Success)
{
await Console.Error.WriteLineAsync($"The episode at {path} does not match the episode's regex.");
return;
}
string showPath = Path.GetDirectoryName(path);
string collectionName = match.Groups["Collection"].Value;
string showName = match.Groups["Show"].Value;
int? seasonNumber = int.TryParse(match.Groups["Season"].Value, out int tmp) ? tmp : null;
int? episodeNumber = int.TryParse(match.Groups["Episode"].Value, out tmp) ? tmp : null;
int? absoluteNumber = int.TryParse(match.Groups["Absolute"].Value, out tmp) ? tmp : null;
Collection collection = await GetCollection(libraryManager, collectionName, library);
bool isMovie = seasonNumber == null && episodeNumber == null && absoluteNumber == null;
Show show = await GetShow(libraryManager, showName, showPath, isMovie, library);
if (isMovie)
await libraryManager!.Create(await GetMovie(show, path));
else
string[] paths = shows.Select(x => x.First())
.Concat(shows.SelectMany(x => x.Skip(1)))
.ToArray();
float percent = 0;
IProgress<float> reporter = new Progress<float>(x =>
{
Season season = seasonNumber != null
? await GetSeason(libraryManager, show, seasonNumber.Value, library)
: null;
Episode episode = await GetEpisode(libraryManager,
show,
season,
episodeNumber,
absoluteNumber,
path,
library);
await libraryManager!.Create(episode);
// ReSharper disable once AccessToModifiedClosure
progress.Report((percent + x / paths.Length - 10) / library.Paths.Length);
});
foreach (string episodePath in paths)
{
_taskManager.StartTask<RegisterEpisode>(reporter, new Dictionary<string, object>
{
["path"] = episodePath,
["library"] = library
}, cancellationToken);
percent += 100f / paths.Length;
}
await libraryManager.AddShowLink(show, library, collection);
Console.WriteLine($"Episode at {path} registered.");
}
catch (DuplicatedItemException ex)
{
await Console.Error.WriteLineAsync($"{path}: {ex.Message}");
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"Unknown exception thrown while registering episode at {path}." +
$"\nException: {ex.Message}" +
$"\n{ex.StackTrace}");
}
}
private async Task<Collection> GetCollection(ILibraryManager libraryManager,
string collectionName,
Library library)
{
if (string.IsNullOrEmpty(collectionName))
return null;
Collection collection = await libraryManager.GetOrDefault<Collection>(Utility.ToSlug(collectionName));
if (collection != null)
return collection;
collection = await MetadataProvider.GetCollectionFromName(collectionName, library);
try
{
await libraryManager.Create(collection);
return collection;
}
catch (DuplicatedItemException)
{
return await libraryManager.GetOrDefault<Collection>(collection.Slug);
}
}
private async Task<Show> GetShow(ILibraryManager libraryManager,
string showTitle,
string showPath,
bool isMovie,
Library library)
{
Show old = await libraryManager.GetOrDefault<Show>(x => x.Path == showPath);
if (old != null)
{
await libraryManager.Load(old, x => x.ExternalIDs);
return old;
}
Show show = await MetadataProvider.SearchShow(showTitle, isMovie, library);
show.Path = showPath;
show.People = await MetadataProvider.GetPeople(show, library);
try
{
show = await libraryManager.Create(show);
}
catch (DuplicatedItemException)
{
old = await libraryManager.GetOrDefault<Show>(show.Slug);
if (old != null && old.Path == showPath)
string[] subtitles = files
.Where(FileExtensions.IsSubtitle)
.Where(x => !x.Contains("/Extra/"))
.Where(x => tracks.All(y => y.Path != x))
.ToArray();
percent = 0;
reporter = new Progress<float>(x =>
{
await libraryManager.Load(old, x => x.ExternalIDs);
return old;
}
if (show.StartAir != null)
// ReSharper disable once AccessToModifiedClosure
progress.Report((90 + (percent + x / subtitles.Length)) / library.Paths.Length);
});
foreach (string trackPath in subtitles)
{
show.Slug += $"-{show.StartAir.Value.Year}";
await libraryManager.Create(show);
_taskManager.StartTask<RegisterSubtitle>(reporter, new Dictionary<string, object>
{
["path"] = trackPath
}, cancellationToken);
percent += 100f / subtitles.Length;
}
else
throw;
}
await ThumbnailsManager.Validate(show);
return show;
}
private async Task<Season> GetSeason(ILibraryManager libraryManager,
Show show,
int seasonNumber,
Library library)
{
try
{
Season season = await libraryManager.Get(show.Slug, seasonNumber);
season.Show = show;
return season;
}
catch (ItemNotFoundException)
{
Season season = await MetadataProvider.GetSeason(show, seasonNumber, library);
try
{
await libraryManager.Create(season);
await ThumbnailsManager.Validate(season);
}
catch (DuplicatedItemException)
{
season = await libraryManager.Get(show.Slug, seasonNumber);
}
season.Show = show;
return season;
}
}
private async Task<Episode> GetEpisode(ILibraryManager libraryManager,
Show show,
Season season,
int? episodeNumber,
int? absoluteNumber,
string episodePath,
Library library)
{
Episode episode = await MetadataProvider.GetEpisode(show,
episodePath,
season?.SeasonNumber,
episodeNumber,
absoluteNumber,
library);
if (episode.SeasonNumber != null)
{
season ??= await GetSeason(libraryManager, show, episode.SeasonNumber.Value, library);
episode.Season = season;
episode.SeasonID = season?.ID;
}
await ThumbnailsManager.Validate(episode);
await GetTracks(episode);
return episode;
}
private async Task<Episode> GetMovie(Show show, string episodePath)
{
Episode episode = new()
{
Title = show.Title,
Path = episodePath,
Show = show,
ShowID = show.ID,
ShowSlug = show.Slug
};
episode.Tracks = await GetTracks(episode);
return episode;
}
private async Task<ICollection<Track>> GetTracks(Episode episode)
{
episode.Tracks = (await Transcoder.ExtractInfos(episode, false))
.Where(x => x.Type != StreamType.Attachment)
.ToArray();
return episode.Tracks;
}
private static readonly string[] VideoExtensions =
{
".webm",
".mkv",
".flv",
".vob",
".ogg",
".ogv",
".avi",
".mts",
".m2ts",
".ts",
".mov",
".qt",
".asf",
".mp4",
".m4p",
".m4v",
".mpg",
".mp2",
".mpeg",
".mpe",
".mpv",
".m2v",
".3gp",
".3g2"
};
private static bool IsVideo(string filePath)
{
return VideoExtensions.Contains(Path.GetExtension(filePath));
}
private static readonly Dictionary<string, string> SubtitleExtensions = new()
{
{".ass", "ass"},
{".str", "subrip"}
};
private static bool IsSubtitle(string filePath)
{
return SubtitleExtensions.ContainsKey(Path.GetExtension(filePath));
}
}
}

Some files were not shown because too many files have changed in this diff Show More