diff --git a/Kyoo.Common/Controllers/IIdentifier.cs b/Kyoo.Common/Controllers/IIdentifier.cs index 11aeb65d..5d11c53b 100644 --- a/Kyoo.Common/Controllers/IIdentifier.cs +++ b/Kyoo.Common/Controllers/IIdentifier.cs @@ -12,32 +12,26 @@ namespace Kyoo.Controllers /// /// Identify a path and return the parsed metadata. /// - /// - /// The path of the episode file to parse. - /// - /// - /// The path of the episode file relative to the library root. It starts with a /. - /// - /// The identifier could not work for the given path. + /// The path of the episode file to parse. + /// + /// The identifier could not work for the given path. + /// /// /// A tuple of models representing parsed metadata. /// If no metadata could be parsed for a type, null can be returned. /// - Task<(Collection, Show, Season, Episode)> Identify(string path, string relativePath); + Task<(Collection, Show, Season, Episode)> Identify(string path); /// /// Identify an external subtitle or track file from it's path and return the parsed metadata. /// - /// - /// The path of the external track file to parse. - /// - /// - /// The path of the episode file relative to the library root. It starts with a /. - /// - /// The identifier could not work for the given path. + /// The path of the external track file to parse. + /// + /// The identifier could not work for the given path. + /// /// /// The metadata of the track identified. /// - Task IdentifyTrack(string path, string relativePath); + Task IdentifyTrack(string path); } } \ No newline at end of file diff --git a/Kyoo.Common/Controllers/ITask.cs b/Kyoo.Common/Controllers/ITask.cs index e5fbce29..e8d07000 100644 --- a/Kyoo.Common/Controllers/ITask.cs +++ b/Kyoo.Common/Controllers/ITask.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using Kyoo.Models; using Kyoo.Models.Attributes; using Kyoo.Models.Exceptions; @@ -116,6 +117,15 @@ namespace Kyoo.Controllers { 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)); } } diff --git a/Kyoo.Tests/Database/RepositoryTests.cs b/Kyoo.Tests/Database/RepositoryTests.cs index 51db3061..4cc72b4c 100644 --- a/Kyoo.Tests/Database/RepositoryTests.cs +++ b/Kyoo.Tests/Database/RepositoryTests.cs @@ -25,6 +25,7 @@ namespace Kyoo.Tests public void Dispose() { Repositories.Dispose(); + GC.SuppressFinalize(this); } public ValueTask DisposeAsync() diff --git a/Kyoo.Tests/Identifier/IdentifierTests.cs b/Kyoo.Tests/Identifier/IdentifierTests.cs new file mode 100644 index 00000000..170a1352 --- /dev/null +++ b/Kyoo.Tests/Identifier/IdentifierTests.cs @@ -0,0 +1,135 @@ +using System.Threading.Tasks; +using Kyoo.Controllers; +using Kyoo.Models; +using Kyoo.Models.Options; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Kyoo.Tests.Identifier +{ + public class Identifier + { + private readonly Mock _manager; + private readonly IIdentifier _identifier; + + public Identifier() + { + Mock> options = new(); + options.Setup(x => x.CurrentValue).Returns(new MediaOptions + { + Regex = new [] + { + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? S(?\\d+)E(?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? (?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))?\\..*$" + } + }); + + _manager = new Mock(); + _identifier = new RegexIdentifier(options.Object, _manager.Object); + } + + + [Fact] + public async Task EpisodeIdentification() + { + _manager.Setup(x => x.GetAll(null, new Sort(), 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(), 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(), 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(), 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(), 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); + } + } +} \ No newline at end of file diff --git a/Kyoo.Tests/Kyoo.Tests.csproj b/Kyoo.Tests/Kyoo.Tests.csproj index d198dae6..9dce41fc 100644 --- a/Kyoo.Tests/Kyoo.Tests.csproj +++ b/Kyoo.Tests/Kyoo.Tests.csproj @@ -17,6 +17,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Kyoo/Controllers/RegexIdentifier.cs b/Kyoo/Controllers/RegexIdentifier.cs index 3c09ae4a..c1a8ab45 100644 --- a/Kyoo/Controllers/RegexIdentifier.cs +++ b/Kyoo/Controllers/RegexIdentifier.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Kyoo.Models; @@ -18,24 +19,48 @@ namespace Kyoo.Controllers /// /// The configuration of kyoo to retrieve the identifier regex. /// - private readonly IOptions _configuration; + private readonly IOptionsMonitor _configuration; + /// + /// The library manager used to retrieve libraries paths. + /// + private readonly ILibraryManager _libraryManager; /// /// Create a new . /// /// The regex patterns to use. - public RegexIdentifier(IOptions configuration) + /// The library manager used to retrieve libraries paths. + public RegexIdentifier(IOptionsMonitor configuration, ILibraryManager libraryManager) { _configuration = configuration; + _libraryManager = libraryManager; + } + + /// + /// Retrieve the relative path of an episode or subtitle. + /// + /// The full path of the episode + /// The path relative to the library root. + private async Task _GetRelativePath(string path) + { + string libraryPath = (await _libraryManager.GetAll()) + .SelectMany(x => x.Paths) + .Where(path.StartsWith) + .OrderByDescending(x => x.Length) + .FirstOrDefault(); + return path[(libraryPath?.Length ?? 0)..]; } /// - public Task<(Collection, Show, Season, Episode)> Identify(string path, string relativePath) + public async Task<(Collection, Show, Season, Episode)> Identify(string path) { - Regex regex = new(_configuration.Value.Regex, RegexOptions.IgnoreCase | RegexOptions.Compiled); - Match match = regex.Match(relativePath); + 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.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 = ( @@ -80,24 +105,27 @@ namespace Kyoo.Controllers ret.episode.Title = ret.show.Title; } - return Task.FromResult(ret); + return ret; } /// - public Task IdentifyTrack(string path, string relativePath) + public async Task IdentifyTrack(string path) { - Regex regex = new(_configuration.Value.SubtitleRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled); - Match match = regex.Match(path); + string relativePath = await _GetRelativePath(path); + Match match = _configuration.CurrentValue.SubtitleRegex + .Select(x => new Regex(x, RegexOptions.IgnoreCase | RegexOptions.Compiled)) + .Select(x => x.Match(relativePath)) + .FirstOrDefault(x => x.Success); - if (!match.Success) + if (match == null) throw new IdentificationFailedException($"The subtitle at {path} does not match the subtitle's regex."); string episodePath = match.Groups["Episode"].Value; - return Task.FromResult(new Track + return new Track { Type = StreamType.Subtitle, Language = match.Groups["Language"].Value, - IsDefault = match.Groups["Default"].Value.Length > 0, + IsDefault = match.Groups["Default"].Value.Length > 0, IsForced = match.Groups["Forced"].Value.Length > 0, Codec = FileExtensions.SubtitleExtensions[Path.GetExtension(path)], IsExternal = true, @@ -106,7 +134,7 @@ namespace Kyoo.Controllers { Path = episodePath } - }); + }; } } } \ No newline at end of file diff --git a/Kyoo/Models/Options/MediaOptions.cs b/Kyoo/Models/Options/MediaOptions.cs index d53b13d0..4b02e4db 100644 --- a/Kyoo/Models/Options/MediaOptions.cs +++ b/Kyoo/Models/Options/MediaOptions.cs @@ -13,11 +13,11 @@ namespace Kyoo.Models.Options /// /// A regex for episodes /// - public string Regex { get; set; } + public string[] Regex { get; set; } /// /// A regex for subtitles /// - public string SubtitleRegex { get; set; } + public string[] SubtitleRegex { get; set; } } } \ No newline at end of file diff --git a/Kyoo/Tasks/RegisterEpisode.cs b/Kyoo/Tasks/RegisterEpisode.cs index dea3bf40..5541e6fa 100644 --- a/Kyoo/Tasks/RegisterEpisode.cs +++ b/Kyoo/Tasks/RegisterEpisode.cs @@ -73,8 +73,6 @@ namespace Kyoo.Tasks return new() { TaskParameter.CreateRequired("path", "The path of the episode file"), - TaskParameter.CreateRequired("relativePath", - "The path of the episode file relative to the library root. It starts with a /."), TaskParameter.CreateRequired("library", "The library in witch the episode is") }; } @@ -83,17 +81,19 @@ namespace Kyoo.Tasks public async Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) { string path = arguments["path"].As(); - string relativePath = arguments["relativePath"].As(); Library library = arguments["library"].As(); progress.Report(0); - - if (library.Providers == null) - await _libraryManager.Load(library, x => x.Providers); - _metadataProvider.UseProviders(library.Providers); + + if (library != null) + { + if (library.Providers == null) + await _libraryManager.Load(library, x => x.Providers); + _metadataProvider.UseProviders(library.Providers); + } + try { - (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(path, - relativePath); + (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(path); progress.Report(15); collection = await _RegisterAndFill(collection); diff --git a/Kyoo/Tasks/RegisterSubtitle.cs b/Kyoo/Tasks/RegisterSubtitle.cs index 23a7d425..6c36ea58 100644 --- a/Kyoo/Tasks/RegisterSubtitle.cs +++ b/Kyoo/Tasks/RegisterSubtitle.cs @@ -39,9 +39,7 @@ namespace Kyoo.Tasks { return new() { - TaskParameter.CreateRequired("path", "The path of the subtitle file"), - TaskParameter.CreateRequired("relativePath", - "The path of the subtitle file relative to the library root. It starts with a /.") + TaskParameter.CreateRequired("path", "The path of the subtitle file") }; } @@ -49,12 +47,11 @@ namespace Kyoo.Tasks public async Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) { string path = arguments["path"].As(); - string relativePath = arguments["relativePath"].As(); try { progress.Report(0); - Track track = await _identifier.IdentifyTrack(path, relativePath); + Track track = await _identifier.IdentifyTrack(path); progress.Report(25); if (track.Episode == null) diff --git a/Kyoo/settings.json b/Kyoo/settings.json index ffc9b446..66ff8566 100644 --- a/Kyoo/settings.json +++ b/Kyoo/settings.json @@ -44,8 +44,14 @@ }, "media": { - "regex": "(?:\\/(?.*?))?\\/(?.*?)(?: \\(?\\d+\\))?\\/\\k(?: \\(\\d+\\))?(?:(?: S(?\\d+)E(?\\d+))| (?\\d+))?.*$", - "subtitleRegex": "^(?.*)\\.(?\\w{1,3})\\.(?default\\.)?(?forced\\.)?.*$" + "regex": [ + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? S(?\\d+)E(?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? (?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))?\\..*$" + ], + "subtitleRegex": [ + "^(?.+)\\.(?\\w{1,3})\\.(?default\\.)?(?forced\\.)?.*$" + ] }, "authentication": {