Cleaning up and adding tests for the regex identifier

This commit is contained in:
Zoe Roux 2021-07-23 18:45:31 +02:00
parent 48e81dfd92
commit e55f60166e
10 changed files with 220 additions and 48 deletions

View File

@ -12,32 +12,26 @@ namespace Kyoo.Controllers
/// <summary> /// <summary>
/// Identify a path and return the parsed metadata. /// Identify a path and return the parsed metadata.
/// </summary> /// </summary>
/// <param name="path"> /// <param name="path">The path of the episode file to parse.</param>
/// The path of the episode file to parse. /// <exception cref="IdentificationFailedException">
/// </param> /// The identifier could not work for the given path.
/// <param name="relativePath"> /// </exception>
/// The path of the episode file relative to the library root. It starts with a <c>/</c>.
/// </param>
/// <exception cref="IdentificationFailedException">The identifier could not work for the given path.</exception>
/// <returns> /// <returns>
/// A tuple of models representing parsed metadata. /// A tuple of models representing parsed metadata.
/// If no metadata could be parsed for a type, null can be returned. /// If no metadata could be parsed for a type, null can be returned.
/// </returns> /// </returns>
Task<(Collection, Show, Season, Episode)> Identify(string path, string relativePath); Task<(Collection, Show, Season, Episode)> Identify(string path);
/// <summary> /// <summary>
/// Identify an external subtitle or track file from it's path and return the parsed metadata. /// Identify an external subtitle or track file from it's path and return the parsed metadata.
/// </summary> /// </summary>
/// <param name="path"> /// <param name="path">The path of the external track file to parse.</param>
/// The path of the external track file to parse. /// <exception cref="IdentificationFailedException">
/// </param> /// The identifier could not work for the given path.
/// <param name="relativePath"> /// </exception>
/// The path of the episode file relative to the library root. It starts with a <c>/</c>.
/// </param>
/// <exception cref="IdentificationFailedException">The identifier could not work for the given path.</exception>
/// <returns> /// <returns>
/// The metadata of the track identified. /// The metadata of the track identified.
/// </returns> /// </returns>
Task<Track> IdentifyTrack(string path, string relativePath); Task<Track> IdentifyTrack(string path);
} }
} }

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations; using JetBrains.Annotations;
using Kyoo.Models;
using Kyoo.Models.Attributes; using Kyoo.Models.Attributes;
using Kyoo.Models.Exceptions; using Kyoo.Models.Exceptions;
@ -116,6 +117,15 @@ namespace Kyoo.Controllers
{ {
if (typeof(T) == typeof(object)) if (typeof(T) == typeof(object))
return (T)Value; 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)); return (T)Convert.ChangeType(Value, typeof(T));
} }
} }

View File

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

View File

@ -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<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+\\))?\\..*$"
}
});
_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);
}
}
}

View File

@ -17,6 +17,7 @@
<PackageReference Include="Divergic.Logging.Xunit" Version="3.6.0" /> <PackageReference Include="Divergic.Logging.Xunit" Version="3.6.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Models; using Kyoo.Models;
@ -18,24 +19,48 @@ namespace Kyoo.Controllers
/// <summary> /// <summary>
/// The configuration of kyoo to retrieve the identifier regex. /// The configuration of kyoo to retrieve the identifier regex.
/// </summary> /// </summary>
private readonly IOptions<MediaOptions> _configuration; private readonly IOptionsMonitor<MediaOptions> _configuration;
/// <summary>
/// The library manager used to retrieve libraries paths.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// Create a new <see cref="RegexIdentifier"/>. /// Create a new <see cref="RegexIdentifier"/>.
/// </summary> /// </summary>
/// <param name="configuration">The regex patterns to use.</param> /// <param name="configuration">The regex patterns to use.</param>
public RegexIdentifier(IOptions<MediaOptions> configuration) /// <param name="libraryManager">The library manager used to retrieve libraries paths.</param>
public RegexIdentifier(IOptionsMonitor<MediaOptions> configuration, ILibraryManager libraryManager)
{ {
_configuration = configuration; _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 /> /// <inheritdoc />
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); string relativePath = await _GetRelativePath(path);
Match match = regex.Match(relativePath); 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."); throw new IdentificationFailedException($"The episode at {path} does not match the episode's regex.");
(Collection collection, Show show, Season season, Episode episode) ret = ( (Collection collection, Show show, Season season, Episode episode) ret = (
@ -80,20 +105,23 @@ namespace Kyoo.Controllers
ret.episode.Title = ret.show.Title; ret.episode.Title = ret.show.Title;
} }
return Task.FromResult(ret); return ret;
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<Track> IdentifyTrack(string path, string relativePath) public async Task<Track> IdentifyTrack(string path)
{ {
Regex regex = new(_configuration.Value.SubtitleRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled); string relativePath = await _GetRelativePath(path);
Match match = regex.Match(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."); throw new IdentificationFailedException($"The subtitle at {path} does not match the subtitle's regex.");
string episodePath = match.Groups["Episode"].Value; string episodePath = match.Groups["Episode"].Value;
return Task.FromResult(new Track return new Track
{ {
Type = StreamType.Subtitle, Type = StreamType.Subtitle,
Language = match.Groups["Language"].Value, Language = match.Groups["Language"].Value,
@ -106,7 +134,7 @@ namespace Kyoo.Controllers
{ {
Path = episodePath Path = episodePath
} }
}); };
} }
} }
} }

View File

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

View File

@ -73,8 +73,6 @@ namespace Kyoo.Tasks
return new() return new()
{ {
TaskParameter.CreateRequired<string>("path", "The path of the episode file"), TaskParameter.CreateRequired<string>("path", "The path of the episode file"),
TaskParameter.CreateRequired<string>("relativePath",
"The path of the episode file relative to the library root. It starts with a /."),
TaskParameter.CreateRequired<Library>("library", "The library in witch the episode is") TaskParameter.CreateRequired<Library>("library", "The library in witch the episode is")
}; };
} }
@ -83,17 +81,19 @@ namespace Kyoo.Tasks
public async Task Run(TaskParameters arguments, IProgress<float> progress, CancellationToken cancellationToken) public async Task Run(TaskParameters arguments, IProgress<float> progress, CancellationToken cancellationToken)
{ {
string path = arguments["path"].As<string>(); string path = arguments["path"].As<string>();
string relativePath = arguments["relativePath"].As<string>();
Library library = arguments["library"].As<Library>(); Library library = arguments["library"].As<Library>();
progress.Report(0); progress.Report(0);
if (library != null)
{
if (library.Providers == null) if (library.Providers == null)
await _libraryManager.Load(library, x => x.Providers); await _libraryManager.Load(library, x => x.Providers);
_metadataProvider.UseProviders(library.Providers); _metadataProvider.UseProviders(library.Providers);
}
try try
{ {
(Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(path, (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(path);
relativePath);
progress.Report(15); progress.Report(15);
collection = await _RegisterAndFill(collection); collection = await _RegisterAndFill(collection);

View File

@ -39,9 +39,7 @@ namespace Kyoo.Tasks
{ {
return new() return new()
{ {
TaskParameter.CreateRequired<string>("path", "The path of the subtitle file"), TaskParameter.CreateRequired<string>("path", "The path of the subtitle file")
TaskParameter.CreateRequired<string>("relativePath",
"The path of the subtitle file relative to the library root. It starts with a /.")
}; };
} }
@ -49,12 +47,11 @@ namespace Kyoo.Tasks
public async Task Run(TaskParameters arguments, IProgress<float> progress, CancellationToken cancellationToken) public async Task Run(TaskParameters arguments, IProgress<float> progress, CancellationToken cancellationToken)
{ {
string path = arguments["path"].As<string>(); string path = arguments["path"].As<string>();
string relativePath = arguments["relativePath"].As<string>();
try try
{ {
progress.Report(0); progress.Report(0);
Track track = await _identifier.IdentifyTrack(path, relativePath); Track track = await _identifier.IdentifyTrack(path);
progress.Report(25); progress.Report(25);
if (track.Episode == null) if (track.Episode == null)

View File

@ -44,8 +44,14 @@
}, },
"media": { "media": {
"regex": "(?:\\/(?<Collection>.*?))?\\/(?<Show>.*?)(?: \\(?<StartYear>\\d+\\))?\\/\\k<Show>(?: \\(\\d+\\))?(?:(?: S(?<Season>\\d+)E(?<Episode>\\d+))| (?<Absolute>\\d+))?.*$", "regex": [
"subtitleRegex": "^(?<Episode>.*)\\.(?<Language>\\w{1,3})\\.(?<Default>default\\.)?(?<Forced>forced\\.)?.*$" "^\\/?(?<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": [
"^(?<Episode>.+)\\.(?<Language>\\w{1,3})\\.(?<Default>default\\.)?(?<Forced>forced\\.)?.*$"
]
}, },
"authentication": { "authentication": {