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>
/// Identify a path and return the parsed metadata.
/// </summary>
/// <param name="path">
/// The path of the episode file to parse.
/// </param>
/// <param name="relativePath">
/// 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>
/// <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, string relativePath);
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>
/// <param name="relativePath">
/// 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>
/// <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, string relativePath);
Task<Track> IdentifyTrack(string path);
}
}

View File

@ -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));
}
}

View File

@ -25,6 +25,7 @@ namespace Kyoo.Tests
public void Dispose()
{
Repositories.Dispose();
GC.SuppressFinalize(this);
}
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="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="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -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
/// <summary>
/// The configuration of kyoo to retrieve the identifier regex.
/// </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>
/// Create a new <see cref="RegexIdentifier"/>.
/// </summary>
/// <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;
_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 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;
}
/// <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);
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
}
});
};
}
}
}

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

@ -73,8 +73,6 @@ namespace Kyoo.Tasks
return new()
{
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")
};
}
@ -83,17 +81,19 @@ namespace Kyoo.Tasks
public async Task Run(TaskParameters arguments, IProgress<float> progress, CancellationToken cancellationToken)
{
string path = arguments["path"].As<string>();
string relativePath = arguments["relativePath"].As<string>();
Library library = arguments["library"].As<Library>();
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);

View File

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

View File

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