Handling tracks slugs

This commit is contained in:
Zoe Roux 2021-07-13 15:18:25 +02:00
parent 6bd7b47fd9
commit dc42ed031f
15 changed files with 258 additions and 249 deletions

View File

@ -35,7 +35,7 @@ jobs:
check-name: tests
repo-token: ${{secrets.GITHUB_TOKEN}}
running-workflow-name: analysis
allowed-conclusions: success,skipped,cancelled,neutral,failed
allowed-conclusions: success,skipped,cancelled,neutral,failure
- name: Download coverage report
uses: dawidd6/action-download-artifact@v2
with:

View File

@ -29,8 +29,10 @@ jobs:
POSTGRES_USERNAME: postgres
POSTGRES_PASSWORD: postgres
- name: Sanitize coverage output
if: ${{ always() }}
run: sed -i "s'$(pwd)'.'" Kyoo.Tests/coverage.opencover.xml
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v2
with:
name: coverage.xml

View File

@ -149,16 +149,6 @@ namespace Kyoo.Controllers
[ItemNotNull]
Task<Episode> Get(string showSlug, int seasonNumber, int episodeNumber);
/// <summary>
/// Get a track from it's slug and it's type.
/// </summary>
/// <param name="slug">The slug of the track</param>
/// <param name="type">The type (Video, Audio or Subtitle)</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The track found</returns>
[ItemNotNull]
Task<Track> Get(string slug, StreamType type = StreamType.Unknown);
/// <summary>
/// Get the resource by it's ID or null if it is not found.
/// </summary>
@ -224,15 +214,6 @@ namespace Kyoo.Controllers
[ItemCanBeNull]
Task<Episode> GetOrDefault(string showSlug, int seasonNumber, int episodeNumber);
/// <summary>
/// Get a track from it's slug and it's type or null if it is not found.
/// </summary>
/// <param name="slug">The slug of the track</param>
/// <param name="type">The type (Video, Audio or Subtitle)</param>
/// <returns>The track found</returns>
[ItemCanBeNull]
Task<Track> GetOrDefault(string slug, StreamType type = StreamType.Unknown);
/// <summary>
/// Load a related resource

View File

@ -375,25 +375,7 @@ namespace Kyoo.Controllers
/// <summary>
/// A repository to handle tracks
/// </summary>
public interface ITrackRepository : IRepository<Track>
{
/// <summary>
/// Get a track from it's slug and it's type.
/// </summary>
/// <param name="slug">The slug of the track</param>
/// <param name="type">The type (Video, Audio or Subtitle)</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The track found</returns>
Task<Track> Get(string slug, StreamType type = StreamType.Unknown);
/// <summary>
/// Get a track from it's slug and it's type or null if it is not found.
/// </summary>
/// <param name="slug">The slug of the track</param>
/// <param name="type">The type (Video, Audio or Subtitle)</param>
/// <returns>The track found</returns>
Task<Track> GetOrDefault(string slug, StreamType type = StreamType.Unknown);
}
public interface ITrackRepository : IRepository<Track> { }
/// <summary>
/// A repository to handle libraries.

View File

@ -114,12 +114,6 @@ namespace Kyoo.Controllers
return EpisodeRepository.Get(showSlug, seasonNumber, episodeNumber);
}
/// <inheritdoc />
public Task<Track> Get(string slug, StreamType type = StreamType.Unknown)
{
return TrackRepository.Get(slug, type);
}
/// <inheritdoc />
public async Task<T> GetOrDefault<T>(int id)
where T : class, IResource
@ -165,12 +159,6 @@ namespace Kyoo.Controllers
return await EpisodeRepository.GetOrDefault(showSlug, seasonNumber, episodeNumber);
}
/// <inheritdoc />
public async Task<Track> GetOrDefault(string slug, StreamType type = StreamType.Unknown)
{
return await TrackRepository.GetOrDefault(slug, type);
}
/// <inheritdoc />
public Task<T> Load<T, T2>(T obj, Expression<Func<T, T2>> member)
where T : class, IResource

View File

@ -33,41 +33,26 @@ namespace Kyoo.Models
{
get
{
string type = Type switch
{
StreamType.Subtitle => "",
StreamType.Video => "video.",
StreamType.Audio => "audio.",
StreamType.Attachment => "font.",
_ => ""
};
string type = Type.ToString().ToLower();
string index = TrackIndex != 0 ? $"-{TrackIndex}" : string.Empty;
string codec = Codec switch
{
"subrip" => ".srt",
{} x => $".{x}"
};
return $"{EpisodeSlug}.{type}{Language}{index}{(IsForced ? "-forced" : "")}{codec}";
string episode = EpisodeSlug ?? Episode.Slug ?? EpisodeID.ToString();
return $"{episode}.{Language}{index}{(IsForced ? ".forced" : "")}.{type}";
}
[UsedImplicitly] private set
{
Match match = Regex.Match(value, @"(?<show>.*)-s(?<season>\d+)e(?<episode>\d+)"
+ @"(\.(?<type>\w*))?\.(?<language>.{0,3})(?<forced>-forced)?(\..\w)?");
if (value == null)
throw new ArgumentNullException(nameof(value));
Match match = Regex.Match(value,
@"(?<ep>[^\.]+)\.(?<lang>\w{0,3})(-(?<index>\d+))?(\.(?<forced>forced))?\.(?<type>\w+)(\.\w*)?");
if (!match.Success)
{
match = Regex.Match(value, @"(?<show>.*)\.(?<language>.{0,3})(?<forced>-forced)?(\..\w)?");
if (!match.Success)
throw new ArgumentException("Invalid track slug. " +
"Format: {episodeSlug}.{language}[-forced][.{extension}]");
}
"Format: {episodeSlug}.{language}[-{index}][-forced].{type}[.{extension}]");
EpisodeSlug = Episode.GetSlug(match.Groups["show"].Value,
match.Groups["season"].Success ? int.Parse(match.Groups["season"].Value) : null,
match.Groups["episode"].Success ? int.Parse(match.Groups["episode"].Value) : null);
Language = match.Groups["language"].Value;
EpisodeSlug = match.Groups["ep"].Value;
Language = match.Groups["lang"].Value;
TrackIndex = int.Parse(match.Groups["index"].Value);
IsForced = match.Groups["forced"].Success;
if (match.Groups["type"].Success)
Type = Enum.Parse<StreamType>(match.Groups["type"].Value, true);
}
}
@ -167,5 +152,32 @@ namespace Kyoo.Models
_ => mkvLanguage
};
}
/// <summary>
/// Utility method to edit a track slug (this only return a slug with the modification, nothing is stored)
/// </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)
{
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;
}
}
}

View File

@ -330,8 +330,6 @@ namespace Kyoo
modelBuilder.Entity<Track>()
.Property(x => x.Slug)
.ValueGeneratedOnAddOrUpdate();
// modelBuilder.Ignore<LibraryItem>();
}
/// <summary>
@ -502,52 +500,6 @@ namespace Kyoo
}
}
/// <summary>
/// Save items or retry with a custom method if a duplicate is found.
/// </summary>
/// <param name="obj">The item to save (other changes of this context will also be saved)</param>
/// <param name="onFail">A function to run on fail, the <see cref="obj"/> param wil be mapped.
/// The second parameter is the current retry number.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <typeparam name="T">The type of the item to save</typeparam>
/// <returns>The number of state entries written to the database.</returns>
public Task<T> SaveOrRetry<T>(T obj, Func<T, int, T> onFail, CancellationToken cancellationToken = new())
{
return SaveOrRetry(obj, onFail, 0, cancellationToken);
}
/// <summary>
/// Save items or retry with a custom method if a duplicate is found.
/// </summary>
/// <param name="obj">The item to save (other changes of this context will also be saved)</param>
/// <param name="onFail">A function to run on fail, the <see cref="obj"/> param wil be mapped.
/// The second parameter is the current retry number.</param>
/// <param name="recurse">The current retry number.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <typeparam name="T">The type of the item to save</typeparam>
/// <returns>The number of state entries written to the database.</returns>
private async Task<T> SaveOrRetry<T>(T obj,
Func<T, int, T> onFail,
int recurse,
CancellationToken cancellationToken = new())
{
try
{
await base.SaveChangesAsync(true, cancellationToken);
return obj;
}
catch (DbUpdateException ex) when (IsDuplicateException(ex))
{
recurse++;
return await SaveOrRetry(onFail(obj, recurse), onFail, recurse, cancellationToken);
}
catch (DbUpdateException)
{
DiscardChanges();
throw;
}
}
/// <summary>
/// Check if the exception is a duplicated exception.
/// </summary>

View File

@ -66,17 +66,77 @@ namespace Kyoo.Postgresql.Migrations
WHEN season_number IS NULL AND episode_number IS NULL THEN NEW.slug
WHEN season_number IS NULL THEN CONCAT(NEW.slug, '-', absolute_number)
ELSE CONCAT(NEW.slug, '-s', season_number, 'e', episode_number)
END
WHERE show_id = NEW.id;
END WHERE show_id = NEW.id;
RETURN NEW;
END
$$;");
// language=PostgreSQL
migrationBuilder.Sql(@"
CREATE TRIGGER show_slug_trigger AFTER UPDATE OF slug ON shows
FOR EACH ROW EXECUTE PROCEDURE show_slug_update();");
// language=PostgreSQL
migrationBuilder.Sql(@"
CREATE FUNCTION episode_update_tracks_slug()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE tracks SET slug = CONCAT(
NEW.slug,
'.', language,
CASE (track_index)
WHEN 0 THEN ''
ELSE CONCAT('-', track_index)
END,
CASE (is_forced)
WHEN false THEN ''
ELSE '-forced'
END,
'.', type
) WHERE episode_id = NEW.id;
RETURN NEW;
END;
$$;");
// language=PostgreSQL
migrationBuilder.Sql(@"
CREATE TRIGGER episode_track_slug_trigger AFTER UPDATE OF slug ON episodes
FOR EACH ROW EXECUTE PROCEDURE episode_update_tracks_slug();");
// language=PostgreSQL
migrationBuilder.Sql(@"
CREATE FUNCTION track_slug_update()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
IF NEW.track_index = 0 THEN
NEW.track_index := (SELECT COUNT(*) FROM tracks
WHERE episode_id = NEW.episode_id AND type = NEW.type
AND language = NEW.language AND is_forced = NEW.is_forced);
END IF;
NEW.slug := CONCAT(
(SELECT slug FROM episodes WHERE id = NEW.episode_id),
'.', NEW.language,
CASE (NEW.track_index)
WHEN 0 THEN ''
ELSE CONCAT('-', NEW.track_index)
END,
CASE (NEW.is_forced)
WHEN false THEN ''
ELSE '-forced'
END,
'.', NEW.type
);
RETURN NEW;
END
$$;");
// language=PostgreSQL
migrationBuilder.Sql(@"
CREATE TRIGGER track_slug_trigger
BEFORE INSERT OR UPDATE OF episode_id, is_forced, language, track_index, type ON tracks
FOR EACH ROW EXECUTE PROCEDURE track_slug_update();");
// language=PostgreSQL
migrationBuilder.Sql(@"
@ -112,6 +172,14 @@ namespace Kyoo.Postgresql.Migrations
// language=PostgreSQL
migrationBuilder.Sql(@"DROP FUNCTION episode_slug_update;");
// language=PostgreSQL
migrationBuilder.Sql("DROP TRIGGER track_slug_trigger ON tracks;");
// language=PostgreSQL
migrationBuilder.Sql(@"DROP FUNCTION track_slug_update;");
// language=PostgreSQL
migrationBuilder.Sql("DROP TRIGGER episode_track_slug_trigger ON episodes;");
// language=PostgreSQL
migrationBuilder.Sql(@"DROP FUNCTION episode_update_tracks_slug;");
// language=PostgreSQL
migrationBuilder.Sql(@"DROP VIEW library_items;");
}
}

View File

@ -49,6 +49,91 @@ namespace Kyoo.SqLite.Migrations
WHERE ID == new.ID;
END");
// language=SQLite
migrationBuilder.Sql(@"
CREATE TRIGGER TrackSlugInsert
AFTER INSERT ON Tracks
FOR EACH ROW
BEGIN
UPDATE Tracks SET TrackIndex = (
SELECT COUNT(*) FROM Tracks
WHERE EpisodeID = new.EpisodeID AND Type = new.Type
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 ||
CASE (TrackIndex)
WHEN 0 THEN ''
ELSE '-' || (TrackIndex)
END ||
CASE (IsForced)
WHEN false THEN ''
ELSE '-forced'
END ||
CASE (Type)
WHEN 1 THEN '.video'
WHEN 2 THEN '.audio'
WHEN 3 THEN '.subtitle'
ELSE '.' || Type
END
WHERE ID = new.ID;
END;");
// language=SQLite
migrationBuilder.Sql(@"
CREATE TRIGGER TrackSlugUpdate
AFTER UPDATE OF EpisodeID, IsForced, Language, TrackIndex, Type ON Tracks
FOR EACH ROW
BEGIN
UPDATE Tracks SET TrackIndex = (
SELECT COUNT(*) FROM Tracks
WHERE EpisodeID = new.EpisodeID AND Type = new.Type
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 ||
CASE (TrackIndex)
WHEN 0 THEN ''
ELSE '-' || (TrackIndex)
END ||
CASE (IsForced)
WHEN false THEN ''
ELSE '-forced'
END ||
CASE (Type)
WHEN 1 THEN '.video'
WHEN 2 THEN '.audio'
WHEN 3 THEN '.subtitle'
ELSE '.' || Type
END
WHERE ID = new.ID;
END;");
// language=SQLite
migrationBuilder.Sql(@"
CREATE TRIGGER EpisodeUpdateTracksSlug
AFTER UPDATE OF Slug ON Episodes
FOR EACH ROW
BEGIN
UPDATE Tracks SET Slug =
NEW.Slug ||
'.' || Language ||
CASE (TrackIndex)
WHEN 0 THEN ''
ELSE '-' || TrackIndex
END ||
CASE (IsForced)
WHEN false THEN ''
ELSE '-forced'
END ||
CASE (Type)
WHEN 1 THEN '.video'
WHEN 2 THEN '.audio'
WHEN 3 THEN '.subtitle'
ELSE '.' || Type
END
WHERE EpisodeID = NEW.ID;
END;");
// language=SQLite
migrationBuilder.Sql(@"

View File

@ -1,4 +1,3 @@
using System.Linq;
using System.Reflection;
using JetBrains.Annotations;
using Xunit;

View File

@ -1,3 +1,4 @@
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Xunit;
@ -34,5 +35,17 @@ namespace Kyoo.Tests.Library
{
_repository = repositories.LibraryManager.TrackRepository;
}
[Fact]
public async Task SlugEditTest()
{
await Repositories.LibraryManager.ShowRepository.Edit(new Show
{
ID = 1,
Slug = "new-slug"
}, false);
Track track = await _repository.Get(1);
Assert.Equal("new-slug-s1e1.eng-1.subtitle", track.Slug);
}
}
}

View File

@ -127,7 +127,7 @@ namespace Kyoo.Controllers
if (changed.Tracks != null || resetOld)
{
await Database.Entry(resource).Collection(x => x.Tracks).LoadAsync();
await _tracks.DeleteAll(x => x.EpisodeID == resource.ID);
resource.Tracks = changed.Tracks;
await ValidateTracks(resource);
}
@ -148,14 +148,10 @@ namespace Kyoo.Controllers
/// <returns>The <see cref="resource"/> parameter is returned.</returns>
private async Task<Episode> ValidateTracks(Episode resource)
{
resource.Tracks = await TaskUtils.DefaultIfNull(resource.Tracks?.MapAsync((x, i) =>
resource.Tracks = await TaskUtils.DefaultIfNull(resource.Tracks?.SelectAsync(x =>
{
x.Episode = resource;
// TODO use a trigger for the next line.
x.TrackIndex = resource.Tracks.Take(i).Count(y => x.Language == y.Language
&& x.IsForced == y.IsForced
&& x.Codec == y.Codec
&& x.Type == y.Type);
x.EpisodeSlug = resource.Slug;
return _tracks.Create(x);
}).ToListAsync());
return resource;

View File

@ -1,11 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Kyoo.Models;
using Kyoo.Models.Exceptions;
using Microsoft.EntityFrameworkCore;
namespace Kyoo.Controllers
@ -34,56 +31,6 @@ namespace Kyoo.Controllers
_database = database;
}
/// <inheritdoc />
Task<Track> IRepository<Track>.Get(string slug)
{
return Get(slug);
}
/// <inheritdoc />
public async Task<Track> Get(string slug, StreamType type = StreamType.Unknown)
{
Track ret = await GetOrDefault(slug, type);
if (ret == null)
throw new ItemNotFoundException($"No track found with the slug {slug} and the type {type}.");
return ret;
}
/// <inheritdoc />
public Task<Track> GetOrDefault(string slug, StreamType type = StreamType.Unknown)
{
Match match = Regex.Match(slug,
@"(?<show>.*)-s(?<season>\d+)e(?<episode>\d+)(\.(?<type>\w*))?\.(?<language>.{0,3})(?<forced>-forced)?(\..*)?");
if (!match.Success)
{
if (int.TryParse(slug, out int id))
return GetOrDefault(id);
match = Regex.Match(slug, @"(?<show>.*)\.(?<language>.{0,3})(?<forced>-forced)?(\..*)?");
if (!match.Success)
throw new ArgumentException("Invalid track slug. " +
"Format: {episodeSlug}.{language}[-forced][.{extension}]");
}
string showSlug = match.Groups["show"].Value;
int? seasonNumber = match.Groups["season"].Success ? int.Parse(match.Groups["season"].Value) : null;
int? episodeNumber = match.Groups["episode"].Success ? int.Parse(match.Groups["episode"].Value) : null;
string language = match.Groups["language"].Value;
bool forced = match.Groups["forced"].Success;
if (match.Groups["type"].Success)
type = Enum.Parse<StreamType>(match.Groups["type"].Value, true);
IQueryable<Track> query = _database.Tracks.Where(x => x.Episode.Show.Slug == showSlug
&& x.Episode.SeasonNumber == seasonNumber
&& x.Episode.EpisodeNumber == episodeNumber
&& x.Language == language
&& x.IsForced == forced);
if (type != StreamType.Unknown)
return query.FirstOrDefaultAsync(x => x.Type == type);
return query.FirstOrDefaultAsync();
}
/// <inheritdoc />
public override Task<ICollection<Track>> Search(string query)
{
@ -93,6 +40,9 @@ namespace Kyoo.Controllers
/// <inheritdoc />
public override async Task<Track> Create(Track obj)
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
if (obj.EpisodeID <= 0)
{
obj.EpisodeID = obj.Episode?.ID ?? 0;
@ -102,14 +52,7 @@ namespace Kyoo.Controllers
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
// ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local
await _database.SaveOrRetry(obj, (x, i) =>
{
if (i > 10)
throw new DuplicatedItemException($"More than 10 same tracks exists {x.Slug}. Aborting...");
x.TrackIndex++;
return x;
});
await _database.SaveChangesAsync();
return obj;
}

View File

@ -5,7 +5,6 @@ using Kyoo.Controllers;
using Kyoo.Models;
using Kyoo.Models.Options;
using Kyoo.Postgresql;
using Kyoo.SqLite;
using Kyoo.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;

View File

@ -1,5 +1,4 @@
using System;
using Kyoo.Models;
using Kyoo.Models;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.IO;
@ -27,19 +26,9 @@ namespace Kyoo.Api
[Permission(nameof(SubtitleApi), Kind.Read)]
public async Task<IActionResult> GetSubtitle(string slug, string extension)
{
Track subtitle;
try
{
subtitle = await _libraryManager.GetOrDefault(slug, StreamType.Subtitle);
}
catch (ArgumentException ex)
{
return BadRequest(new {error = ex.Message});
}
if (subtitle is not {Type: StreamType.Subtitle})
Track subtitle = await _libraryManager.GetOrDefault<Track>(Track.EditSlug(slug, StreamType.Subtitle));
if (subtitle == null)
return NotFound();
if (subtitle.Codec == "subrip" && extension == "vtt")
return new ConvertSubripToVtt(subtitle.Path, _files);
return _files.FileResult(subtitle.Path);