Browse Source

fix maintaining embedded text subtitles from media server (#2453)

* properly reset extracted flag on subtitles

* optimize subtitle updates; extract after targeted deep scan

* fix extracted text subtitle playback from media servers
pull/2454/head
Jason Dove 11 months ago committed by GitHub
parent
commit
ddc1120904
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 24
      ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs
  3. 5
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitles.cs
  4. 125
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs
  5. 350
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  6. 355
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs
  7. 13
      ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs
  8. 107
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  9. 3
      ErsatzTV/Services/WorkerService.cs

4
CHANGELOG.md

@ -66,7 +66,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -66,7 +66,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix slow startup caused by check for overlapping playout items
- Fix green line in *most* cases when overlaying content using NVIDIA acceleration and H264 output
- Fix non-SRT (e.g. SSA/ASS) external subtitle playback from media servers
- Clean up some invalid external subtitle data to prevent playback failures
- Fix extracted text subtitle playback from media servers
- Fix extracted text subtitles getting into invalid state after media server deep scans
- Targeted deep scans will now extract text subtitles for the scanned show
### Changed
- Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration

24
ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
using System.Threading.Channels;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
@ -15,6 +17,7 @@ public class QueueShowScanByLibraryIdHandler( @@ -15,6 +17,7 @@ public class QueueShowScanByLibraryIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IEntityLocker locker,
IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<QueueShowScanByLibraryIdHandler> logger)
: IRequestHandler<QueueShowScanByLibraryId, bool>
{
@ -57,30 +60,41 @@ public class QueueShowScanByLibraryIdHandler( @@ -57,30 +60,41 @@ public class QueueShowScanByLibraryIdHandler(
try
{
var success = false;
switch (library)
{
case PlexLibrary:
Either<BaseError, string> plexResult = await mediator.Send(
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
return plexResult.IsRight;
success = plexResult.IsRight;
break;
case JellyfinLibrary:
Either<BaseError, string> jellyfinResult = await mediator.Send(
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
return jellyfinResult.IsRight;
success = jellyfinResult.IsRight;
break;
case EmbyLibrary:
Either<BaseError, string> embyResult = await mediator.Send(
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
return embyResult.IsRight;
success = embyResult.IsRight;
break;
case LocalLibrary:
logger.LogWarning("Single show scanning is not supported for local libraries");
return false;
break;
default:
logger.LogWarning("Unknown library type for library {Id}", library.Id);
return false;
break;
}
if (success && request.DeepScan)
{
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
}
return success;
}
finally
{

5
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitles.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Subtitles;
public record ExtractEmbeddedShowSubtitles(int ShowId) : IBackgroundServiceRequest, IRequest<Option<BaseError>>;

125
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
using System.Threading.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Dapper;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Subtitles;
public class ExtractEmbeddedShowSubtitlesHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
: ExtractEmbeddedSubtitlesHandlerBase(localFileSystem, logger),
IRequestHandler<ExtractEmbeddedShowSubtitles, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(
ExtractEmbeddedShowSubtitles request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await FFmpegPathMustExist(dbContext, cancellationToken);
return await validation.Match(
async ffmpegPath =>
{
Option<BaseError> result = await ExtractAll(dbContext, request, ffmpegPath, cancellationToken);
await workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);
return result;
},
error => Task.FromResult<Option<BaseError>>(error.Join()));
}
private async Task<Option<BaseError>> ExtractAll(
TvContext dbContext,
ExtractEmbeddedShowSubtitles request,
string ffmpegPath,
CancellationToken cancellationToken)
{
try
{
bool useEmbeddedSubtitles = await configElementRepository
.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken)
.IfNoneAsync(true);
if (!useEmbeddedSubtitles)
{
return Option<BaseError>.None;
}
bool extractEmbeddedSubtitles = await configElementRepository
.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken)
.IfNoneAsync(false);
if (!extractEmbeddedSubtitles)
{
return Option<BaseError>.None;
}
Option<Show> maybeShow = await dbContext.Shows
.AsNoTracking()
.Include(s => s.ShowMetadata)
.SelectOneAsync(s => s.Id, s => s.Id == request.ShowId, cancellationToken);
foreach (ShowMetadata showMetadata in maybeShow.SelectMany(s => s.ShowMetadata).HeadOrNone())
{
logger.LogDebug("Checking show {ShowTitle} for text subtitles to extract", showMetadata.Title);
// check for episodes with not-extracted subtitles
List<int> episodeIds = await dbContext.Connection.QueryAsync<int>(
"""
SELECT DISTINCT E.Id
FROM Episode E
INNER JOIN Season S ON S.Id = E.SeasonId
INNER JOIN EpisodeMetadata EM ON EM.EpisodeId = E.Id
INNER JOIN Subtitle SUB ON SUB.EpisodeMetadataId = EM.Id
WHERE S.ShowId = @ShowId AND SUB.SubtitleKind=0 AND (SUB.IsExtracted=0 OR SUB.Path IS NULL)
""",
new { request.ShowId })
.Map(result => result.ToList());
// filter for items with text subtitles or font attachments
List<int> episodeIdsWithTextSubtitles =
await GetMediaItemIdsWithTextSubtitles(dbContext, episodeIds, cancellationToken);
logger.LogDebug(
"Show {ShowTitle} has {EpisodeCount} episodes with text subtitles to extract",
showMetadata.Title,
episodeIdsWithTextSubtitles.Count);
for (var i = 0; i < episodeIdsWithTextSubtitles.Count; i++)
{
logger.LogDebug(
"Extracting text subtitles for show {ShowTitle} - {Current} of {Total}",
showMetadata.Title,
i + 1,
episodeIdsWithTextSubtitles.Count);
if (cancellationToken.IsCancellationRequested)
{
return Option<BaseError>.None;
}
// extract subtitles and fonts for each item and update db
int mediaItemId = episodeIdsWithTextSubtitles[i];
await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken);
await ExtractFonts(dbContext, mediaItemId, ffmpegPath, cancellationToken);
}
logger.LogDebug("Done checking show {ShowTitle} for text subtitles to extract", showMetadata.Title);
}
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
return Option<BaseError>.None;
}
}

350
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs

@ -1,15 +1,7 @@ @@ -1,15 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Channels;
using CliWrap;
using CliWrap.Buffered;
using CliWrap.Builders;
using Dapper;
using System.Threading.Channels;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -20,13 +12,12 @@ using Microsoft.Extensions.Logging; @@ -20,13 +12,12 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Subtitles;
[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSubtitles, Option<BaseError>>
public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBase,
IRequestHandler<ExtractEmbeddedSubtitles, Option<BaseError>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
@ -37,9 +28,9 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu @@ -37,9 +28,9 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
IConfigElementRepository configElementRepository,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
: base(localFileSystem, logger)
{
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_entityLocker = entityLocker;
_configElementRepository = configElementRepository;
_workerChannel = workerChannel;
@ -201,337 +192,4 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu @@ -201,337 +192,4 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
return Option<BaseError>.None;
}
private static async Task<List<int>> GetMediaItemIdsWithTextSubtitles(
TvContext dbContext,
List<int> mediaItemIds,
CancellationToken cancellationToken)
{
var result = new List<int>();
try
{
List<int> episodeIds = await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => mediaItemIds.Contains(em.EpisodeId))
.Filter(em => em.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(em => em.EpisodeId)
.ToListAsync(cancellationToken);
result.AddRange(episodeIds);
List<int> movieIds = await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => mediaItemIds.Contains(mm.MovieId))
.Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(mm => mm.MovieId)
.ToListAsync(cancellationToken);
result.AddRange(movieIds);
List<int> musicVideoIds = await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mm => mediaItemIds.Contains(mm.MusicVideoId))
.Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(mm => mm.MusicVideoId)
.ToListAsync(cancellationToken);
result.AddRange(musicVideoIds);
List<int> otherVideoIds = await dbContext.OtherVideoMetadata
.AsNoTracking()
.Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
.Filter(ovm => ovm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(ovm => ovm.OtherVideoId)
.ToListAsync(cancellationToken);
result.AddRange(otherVideoIds);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
return result;
}
private async Task ExtractSubtitles(
TvContext dbContext,
int mediaItemId,
string ffmpegPath,
CancellationToken cancellationToken)
{
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken))
{
foreach (List<Subtitle> allSubtitles in GetSubtitles(mediaItem))
{
var subtitlesToExtract = new List<SubtitleToExtract>();
// find each subtitle that needs extraction
IEnumerable<Subtitle> subtitles = allSubtitles
.Filter(s => s.SubtitleKind == SubtitleKind.Embedded)
.Filter(s => s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")
.Filter(s => !s.IsExtracted || string.IsNullOrWhiteSpace(s.Path) ||
FileDoesntExist(mediaItem.Id, s));
// find cache paths for each subtitle
foreach (Subtitle subtitle in subtitles)
{
Option<string> maybePath = GetRelativeOutputPath(mediaItem.Id, subtitle);
foreach (string path in maybePath)
{
subtitlesToExtract.Add(new SubtitleToExtract(subtitle, path));
}
}
if (subtitlesToExtract.Count == 0)
{
continue;
}
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem);
ArgumentsBuilder args = new ArgumentsBuilder()
.Add("-nostdin")
.Add("-hide_banner")
.Add("-i").Add(mediaItemPath);
foreach (SubtitleToExtract subtitle in subtitlesToExtract)
{
string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath));
if (_localFileSystem.FileExists(fullOutputPath))
{
File.Delete(fullOutputPath);
}
args.Add("-map").Add($"0:{subtitle.Subtitle.StreamIndex}")
.Add("-c:s").Add(subtitle.Subtitle.Codec == "mov_text" ? "text" : "copy")
.Add(fullOutputPath);
}
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(args.Build())
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
if (result.ExitCode == 0)
{
foreach (SubtitleToExtract subtitle in subtitlesToExtract)
{
await dbContext.Connection.ExecuteAsync(
"UPDATE `Subtitle` SET `IsExtracted` = 1, `Path` = @Path WHERE `Id` = @SubtitleId",
new { SubtitleId = subtitle.Subtitle.Id, Path = subtitle.OutputPath });
}
_logger.LogDebug("Successfully extracted {Count} subtitles", subtitlesToExtract.Count);
}
else
{
_logger.LogError("Failed to extract subtitles. {Error}", result.StandardError);
}
}
}
}
private bool FileDoesntExist(int mediaItemId, Subtitle subtitle)
{
foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle))
{
return !_localFileSystem.FileExists(path);
}
return false;
}
private static async Task<Option<MediaItem>> GetMediaItem(
TvContext dbContext,
int mediaItemId,
CancellationToken cancellationToken) =>
await dbContext.MediaItems
.AsNoTracking()
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(em => em.Subtitles)
.SelectOneAsync(e => e.Id, e => e.Id == mediaItemId, cancellationToken);
private static Option<List<Subtitle>> GetSubtitles(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => e.EpisodeMetadata.Head().Subtitles,
Movie m => m.MovieMetadata.Head().Subtitles,
MusicVideo mv => mv.MusicVideoMetadata.Head().Subtitles,
OtherVideo ov => ov.OtherVideoMetadata.Head().Subtitles,
_ => None
};
private async Task ExtractFonts(
TvContext dbContext,
int mediaItemId,
string ffmpegPath,
CancellationToken cancellationToken)
{
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken))
{
MediaVersion headVersion = mediaItem.GetHeadVersion();
var attachments = headVersion.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Attachment)
.OrderBy(s => s.Index)
.ToList();
for (var attachmentIndex = 0; attachmentIndex < attachments.Count; attachmentIndex++)
{
MediaStream fontStream = attachments[attachmentIndex];
if (!(fontStream.MimeType ?? string.Empty).Contains("font") &&
!(fontStream.MimeType ?? string.Empty).Contains("opentype"))
{
// not a font
continue;
}
string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName);
if (_localFileSystem.FileExists(fullOutputPath))
{
// already extracted
continue;
}
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem);
var arguments =
$"-nostdin -hide_banner -dump_attachment:t:{attachmentIndex} \"\" -i \"{mediaItemPath}\" -y";
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithWorkingDirectory(FileSystemLayout.FontsCacheFolder)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
// ffmpeg seems to return exit code 1 in all cases when dumping an attachment
// so ignore it and check success a different way
if (_localFileSystem.FileExists(fullOutputPath))
{
_logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName);
}
else
{
_logger.LogError(
"Failed to extract attached font {Font}. {Error}",
fontStream.FileName,
result.StandardError);
}
}
}
}
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(
TvContext dbContext,
CancellationToken cancellationToken) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
private static Option<string> GetRelativeOutputPath(int mediaItemId, Subtitle subtitle)
{
string name = GetStringHash($"{mediaItemId}_{subtitle.StreamIndex}_{subtitle.Codec}");
string subfolder = name[..2];
string subfolder2 = name[2..4];
string nameWithExtension = subtitle.Codec switch
{
"subrip" or "srt" => $"{name}.srt",
"ass" => $"{name}.ass",
"webvtt" => $"{name}.vtt",
"mov_text" => $"{name}.srt",
_ => string.Empty
};
if (string.IsNullOrWhiteSpace(nameWithExtension))
{
return None;
}
return Path.Combine(subfolder, subfolder2, nameWithExtension);
}
private static async Task<string> GetMediaItemPath(TvContext dbContext, MediaItem mediaItem)
{
MediaVersion version = mediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
switch (file)
{
case PlexMediaFile pmf:
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
@"SELECT PMS.Id FROM PlexMediaSource PMS
INNER JOIN Library L on PMS.Id = L.MediaSourceId
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
WHERE LP.Id = @LibraryPathId",
new { mediaItem.LibraryPathId })
.Map(Optional);
foreach (int plexMediaSourceId in maybeId)
{
return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
}
break;
}
return mediaItem switch
{
JellyfinMovie jellyfinMovie =>
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinMovie.ItemId}",
JellyfinEpisode jellyfinEpisode =>
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinEpisode.ItemId}",
EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyMovie.ItemId}",
EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyEpisode.ItemId}",
_ => file.Path
};
}
private static string GetStringHash(string text)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
byte[] textData = Encoding.UTF8.GetBytes(text);
byte[] hash = MD5.HashData(textData);
return Convert.ToHexString(hash);
}
private sealed record SubtitleToExtract(Subtitle Subtitle, string OutputPath);
}

355
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs

@ -0,0 +1,355 @@ @@ -0,0 +1,355 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
using CliWrap.Builders;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Subtitles;
[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem localFileSystem, ILogger logger)
{
protected static Task<Validation<BaseError, string>> FFmpegPathMustExist(
TvContext dbContext,
CancellationToken cancellationToken) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
protected static async Task<List<int>> GetMediaItemIdsWithTextSubtitles(
TvContext dbContext,
List<int> mediaItemIds,
CancellationToken cancellationToken)
{
var result = new List<int>();
try
{
List<int> episodeIds = await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => mediaItemIds.Contains(em.EpisodeId))
.Filter(em => em.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(em => em.EpisodeId)
.ToListAsync(cancellationToken);
result.AddRange(episodeIds);
List<int> movieIds = await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => mediaItemIds.Contains(mm.MovieId))
.Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(mm => mm.MovieId)
.ToListAsync(cancellationToken);
result.AddRange(movieIds);
List<int> musicVideoIds = await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mm => mediaItemIds.Contains(mm.MusicVideoId))
.Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(mm => mm.MusicVideoId)
.ToListAsync(cancellationToken);
result.AddRange(musicVideoIds);
List<int> otherVideoIds = await dbContext.OtherVideoMetadata
.AsNoTracking()
.Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
.Filter(ovm => ovm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded &&
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" &&
s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs"))
.Map(ovm => ovm.OtherVideoId)
.ToListAsync(cancellationToken);
result.AddRange(otherVideoIds);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
return result;
}
protected async Task ExtractSubtitles(
TvContext dbContext,
int mediaItemId,
string ffmpegPath,
CancellationToken cancellationToken)
{
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken))
{
foreach (List<Subtitle> allSubtitles in GetSubtitles(mediaItem))
{
var subtitlesToExtract = new List<SubtitleToExtract>();
// find each subtitle that needs extraction
IEnumerable<Subtitle> subtitles = allSubtitles
.Filter(s => s.SubtitleKind == SubtitleKind.Embedded)
.Filter(s => s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" &&
s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")
.Filter(s => !s.IsExtracted || string.IsNullOrWhiteSpace(s.Path) ||
FileDoesntExist(mediaItem.Id, s));
// find cache paths for each subtitle
foreach (Subtitle subtitle in subtitles)
{
Option<string> maybePath = GetRelativeOutputPath(mediaItem.Id, subtitle);
foreach (string path in maybePath)
{
subtitlesToExtract.Add(new SubtitleToExtract(subtitle, path));
}
}
if (subtitlesToExtract.Count == 0)
{
continue;
}
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem);
ArgumentsBuilder args = new ArgumentsBuilder()
.Add("-nostdin")
.Add("-hide_banner")
.Add("-i").Add(mediaItemPath);
foreach (SubtitleToExtract subtitle in subtitlesToExtract)
{
string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath));
if (localFileSystem.FileExists(fullOutputPath))
{
File.Delete(fullOutputPath);
}
args.Add("-map").Add($"0:{subtitle.Subtitle.StreamIndex}")
.Add("-c:s").Add(subtitle.Subtitle.Codec == "mov_text" ? "text" : "copy")
.Add(fullOutputPath);
}
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithArguments(args.Build())
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
if (result.ExitCode == 0)
{
foreach (SubtitleToExtract subtitle in subtitlesToExtract)
{
await dbContext.Connection.ExecuteAsync(
"UPDATE `Subtitle` SET `IsExtracted` = 1, `Path` = @Path WHERE `Id` = @SubtitleId",
new { SubtitleId = subtitle.Subtitle.Id, Path = subtitle.OutputPath });
}
logger.LogDebug("Successfully extracted {Count} subtitles", subtitlesToExtract.Count);
}
else
{
logger.LogError("Failed to extract subtitles. {Error}", result.StandardError);
}
}
}
}
protected async Task ExtractFonts(
TvContext dbContext,
int mediaItemId,
string ffmpegPath,
CancellationToken cancellationToken)
{
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken))
{
MediaVersion headVersion = mediaItem.GetHeadVersion();
var attachments = headVersion.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Attachment)
.OrderBy(s => s.Index)
.ToList();
for (var attachmentIndex = 0; attachmentIndex < attachments.Count; attachmentIndex++)
{
MediaStream fontStream = attachments[attachmentIndex];
if (!(fontStream.MimeType ?? string.Empty).Contains("font") &&
!(fontStream.MimeType ?? string.Empty).Contains("opentype"))
{
// not a font
continue;
}
string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName);
if (localFileSystem.FileExists(fullOutputPath))
{
// already extracted
continue;
}
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem);
var arguments =
$"-nostdin -hide_banner -dump_attachment:t:{attachmentIndex} \"\" -i \"{mediaItemPath}\" -y";
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
.WithWorkingDirectory(FileSystemLayout.FontsCacheFolder)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
// ffmpeg seems to return exit code 1 in all cases when dumping an attachment
// so ignore it and check success a different way
if (localFileSystem.FileExists(fullOutputPath))
{
logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName);
}
else
{
logger.LogError(
"Failed to extract attached font {Font}. {Error}",
fontStream.FileName,
result.StandardError);
}
}
}
}
private static async Task<Option<MediaItem>> GetMediaItem(
TvContext dbContext,
int mediaItemId,
CancellationToken cancellationToken) =>
await dbContext.MediaItems
.AsNoTracking()
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(em => em.Subtitles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(em => em.Subtitles)
.SelectOneAsync(e => e.Id, e => e.Id == mediaItemId, cancellationToken);
private static async Task<string> GetMediaItemPath(TvContext dbContext, MediaItem mediaItem)
{
MediaVersion version = mediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
switch (file)
{
case PlexMediaFile pmf:
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
@"SELECT PMS.Id FROM PlexMediaSource PMS
INNER JOIN Library L on PMS.Id = L.MediaSourceId
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
WHERE LP.Id = @LibraryPathId",
new { mediaItem.LibraryPathId })
.Map(Optional);
foreach (int plexMediaSourceId in maybeId)
{
return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
}
break;
}
return mediaItem switch
{
JellyfinMovie jellyfinMovie =>
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinMovie.ItemId}",
JellyfinEpisode jellyfinEpisode =>
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinEpisode.ItemId}",
EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyMovie.ItemId}",
EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyEpisode.ItemId}",
_ => file.Path
};
}
private bool FileDoesntExist(int mediaItemId, Subtitle subtitle)
{
foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle))
{
return !localFileSystem.FileExists(path);
}
return false;
}
private static Option<List<Subtitle>> GetSubtitles(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => e.EpisodeMetadata.Head().Subtitles,
Movie m => m.MovieMetadata.Head().Subtitles,
MusicVideo mv => mv.MusicVideoMetadata.Head().Subtitles,
OtherVideo ov => ov.OtherVideoMetadata.Head().Subtitles,
_ => None
};
private static Option<string> GetRelativeOutputPath(int mediaItemId, Subtitle subtitle)
{
string name = GetStringHash($"{mediaItemId}_{subtitle.StreamIndex}_{subtitle.Codec}");
string subfolder = name[..2];
string subfolder2 = name[2..4];
string nameWithExtension = subtitle.Codec switch
{
"subrip" or "srt" => $"{name}.srt",
"ass" => $"{name}.ass",
"webvtt" => $"{name}.vtt",
"mov_text" => $"{name}.srt",
_ => string.Empty
};
if (string.IsNullOrWhiteSpace(nameWithExtension))
{
return None;
}
return Path.Combine(subfolder, subfolder2, nameWithExtension);
}
private static string GetStringHash(string text)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
byte[] textData = Encoding.UTF8.GetBytes(text);
byte[] hash = MD5.HashData(textData);
return Convert.ToHexString(hash);
}
private sealed record SubtitleToExtract(Subtitle Subtitle, string OutputPath);
}

13
ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs

@ -22,29 +22,28 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa @@ -22,29 +22,28 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa
foreach (var subtitle in maybeSubtitle)
{
string path = subtitle.Path;
if (subtitle is { SubtitleKind: SubtitleKind.Embedded, IsExtracted: true })
{
path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path);
string path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path);
return new SubtitlePathAndCodec(path, subtitle.Codec);
}
foreach (string plexUrl in await GetPlexUrl(request.Id, dbContext, maybeSubtitle))
{
path = plexUrl;
return new SubtitlePathAndCodec(plexUrl, subtitle.Codec);
}
foreach (string jellyfinUrl in await GetJellyfinUrl(request.Id, dbContext, maybeSubtitle))
{
path = jellyfinUrl;
return new SubtitlePathAndCodec(jellyfinUrl, subtitle.Codec);
}
foreach (string embyUrl in await GetEmbyUrl(request.Id, dbContext, maybeSubtitle))
{
path = embyUrl;
return new SubtitlePathAndCodec(embyUrl, subtitle.Codec);
}
return new SubtitlePathAndCodec(path, subtitle.Codec);
return new SubtitlePathAndCodec(subtitle.Path, subtitle.Codec);
}
return BaseError.New($"Unable to locate subtitle with id {request.Id}");

107
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -4,24 +4,14 @@ using ErsatzTV.Core.Extensions; @@ -4,24 +4,14 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class MetadataRepository : IMetadataRepository
public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : IMetadataRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILogger<MetadataRepository> _logger;
public MetadataRepository(IDbContextFactory<TvContext> dbContextFactory, ILogger<MetadataRepository> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<bool> RemoveActor(Actor actor)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Actor WHERE Id = @ActorId",
new { ActorId = actor.Id })
@ -30,14 +20,14 @@ public class MetadataRepository : IMetadataRepository @@ -30,14 +20,14 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> Update(Core.Domain.Metadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
dbContext.Entry(metadata).State = EntityState.Modified;
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<bool> Add(Core.Domain.Metadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
dbContext.Entry(metadata).State = EntityState.Added;
foreach (Genre genre in Optional(metadata.Genres).Flatten())
@ -126,7 +116,7 @@ public class MetadataRepository : IMetadataRepository @@ -126,7 +116,7 @@ public class MetadataRepository : IMetadataRepository
{
int mediaVersionId = mediaItem.GetHeadVersion().Id;
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
.Include(v => v.Streams)
.Include(v => v.Chapters)
@ -247,7 +237,7 @@ public class MetadataRepository : IMetadataRepository @@ -247,7 +237,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> UpdateArtworkPath(Artwork artwork)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, SourcePath = @SourcePath, DateUpdated = @DateUpdated, BlurHash43 = @BlurHash43, BlurHash54 = @BlurHash54, BlurHash64 = @BlurHash64 WHERE Id = @Id",
new
@ -259,7 +249,7 @@ public class MetadataRepository : IMetadataRepository @@ -259,7 +249,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> AddArtwork(Core.Domain.Metadata metadata, Artwork artwork)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
var parameters = new
{
@ -315,7 +305,7 @@ public class MetadataRepository : IMetadataRepository @@ -315,7 +305,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> RemoveArtwork(Core.Domain.Metadata metadata, ArtworkKind artworkKind)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id OR OtherVideoMetadataId = @Id)",
@ -329,7 +319,7 @@ public class MetadataRepository : IMetadataRepository @@ -329,7 +319,7 @@ public class MetadataRepository : IMetadataRepository
string sourcePath,
DateTime lastWriteTime)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
Option<Artwork> maybeExisting = await dbContext.Artwork
.AsNoTracking()
.Filter(a => a.SourcePath == sourcePath && a.ArtworkKind == artworkKind && a.DateUpdated == lastWriteTime)
@ -369,7 +359,7 @@ public class MetadataRepository : IMetadataRepository @@ -369,7 +359,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE ShowMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
@ -377,7 +367,7 @@ public class MetadataRepository : IMetadataRepository @@ -377,7 +367,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE SeasonMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
@ -385,7 +375,7 @@ public class MetadataRepository : IMetadataRepository @@ -385,7 +375,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MovieMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
@ -393,7 +383,7 @@ public class MetadataRepository : IMetadataRepository @@ -393,7 +383,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE EpisodeMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
@ -401,7 +391,7 @@ public class MetadataRepository : IMetadataRepository @@ -401,7 +391,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsExternal(ShowMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE ShowMetadata SET MetadataKind = @Kind WHERE Id = @Id",
new { metadata.Id, Kind = (int)MetadataKind.External }).ToUnit();
@ -409,7 +399,7 @@ public class MetadataRepository : IMetadataRepository @@ -409,7 +399,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE ShowMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
new { metadata.Id, ContentRating = contentRating }).ToUnit();
@ -417,7 +407,7 @@ public class MetadataRepository : IMetadataRepository @@ -417,7 +407,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsExternal(MovieMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MovieMetadata SET MetadataKind = @Kind WHERE Id = @Id",
new { metadata.Id, Kind = (int)MetadataKind.External }).ToUnit();
@ -425,7 +415,7 @@ public class MetadataRepository : IMetadataRepository @@ -425,7 +415,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MovieMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
new { metadata.Id, ContentRating = contentRating }).ToUnit();
@ -433,7 +423,7 @@ public class MetadataRepository : IMetadataRepository @@ -433,7 +423,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsUpdated(OtherVideoMetadata metadata, DateTime dateUpdated)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE OtherVideoMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
@ -441,7 +431,7 @@ public class MetadataRepository : IMetadataRepository @@ -441,7 +431,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> MarkAsExternal(OtherVideoMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE OtherVideoMetadata SET MetadataKind = @Kind WHERE Id = @Id",
new { metadata.Id, Kind = (int)MetadataKind.External }).ToUnit();
@ -449,7 +439,7 @@ public class MetadataRepository : IMetadataRepository @@ -449,7 +439,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> SetContentRating(OtherVideoMetadata metadata, string contentRating)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE OtherVideoMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
new { metadata.Id, ContentRating = contentRating }).ToUnit();
@ -457,7 +447,7 @@ public class MetadataRepository : IMetadataRepository @@ -457,7 +447,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> SetPlot(MovieMetadata metadata, string plot)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MovieMetadata SET Plot = @Plot WHERE Id = @Id",
new { metadata.Id, Plot = plot }).ToUnit();
@ -465,7 +455,7 @@ public class MetadataRepository : IMetadataRepository @@ -465,7 +455,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<Unit> SetPlot(OtherVideoMetadata metadata, string plot)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE OtherVideoMetadata SET Plot = @Plot WHERE Id = @Id",
new { metadata.Id, Plot = plot }).ToUnit();
@ -473,7 +463,7 @@ public class MetadataRepository : IMetadataRepository @@ -473,7 +463,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveGuid(MetadataGuid guid)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM MetadataGuid WHERE Id = @GuidId",
new { GuidId = guid.Id })
@ -482,7 +472,7 @@ public class MetadataRepository : IMetadataRepository @@ -482,7 +472,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> AddGuid(Core.Domain.Metadata metadata, MetadataGuid guid)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return metadata switch
{
MovieMetadata =>
@ -515,7 +505,7 @@ public class MetadataRepository : IMetadataRepository @@ -515,7 +505,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveDirector(Director director)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Director WHERE Id = @DirectorId",
new { DirectorId = director.Id })
@ -524,7 +514,7 @@ public class MetadataRepository : IMetadataRepository @@ -524,7 +514,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveWriter(Writer writer)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Writer WHERE Id = @WriterId",
new { WriterId = writer.Id })
@ -536,7 +526,7 @@ public class MetadataRepository : IMetadataRepository @@ -536,7 +526,7 @@ public class MetadataRepository : IMetadataRepository
List<Subtitle> subtitles,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await UpdateSubtitles(dbContext, metadata, subtitles, cancellationToken);
}
@ -545,7 +535,7 @@ public class MetadataRepository : IMetadataRepository @@ -545,7 +535,7 @@ public class MetadataRepository : IMetadataRepository
List<MediaChapter> chapters,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<MediaVersion> maybeExisting = await dbContext.MediaVersions
.Include(mv => mv.Chapters)
@ -586,7 +576,7 @@ public class MetadataRepository : IMetadataRepository @@ -586,7 +576,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveGenre(Genre genre)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Genre WHERE Id = @GenreId",
new { GenreId = genre.Id })
@ -595,7 +585,7 @@ public class MetadataRepository : IMetadataRepository @@ -595,7 +585,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveTag(Tag tag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Tag WHERE Id = @TagId AND ExternalCollectionId = @ExternalCollectionId",
new { TagId = tag.Id, tag.ExternalCollectionId })
@ -604,7 +594,7 @@ public class MetadataRepository : IMetadataRepository @@ -604,7 +594,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveStudio(Studio studio)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Studio WHERE Id = @StudioId",
new { StudioId = studio.Id })
@ -613,7 +603,7 @@ public class MetadataRepository : IMetadataRepository @@ -613,7 +603,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveStyle(Style style)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Style WHERE Id = @StyleId",
new { StyleId = style.Id })
@ -622,7 +612,7 @@ public class MetadataRepository : IMetadataRepository @@ -622,7 +612,7 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveMood(Mood mood)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync("DELETE FROM Mood WHERE Id = @MoodId", new { MoodId = mood.Id })
.Map(result => result > 0);
}
@ -692,14 +682,41 @@ public class MetadataRepository : IMetadataRepository @@ -692,14 +682,41 @@ public class MetadataRepository : IMetadataRepository
Subtitle existingSubtitle =
existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex);
existingSubtitle.Codec = incomingSubtitle.Codec;
// when the kind, codec, language, or flags change we need to extract again
if (existingSubtitle.IsExtracted)
{
bool differentKind = existingSubtitle.SubtitleKind != incomingSubtitle.SubtitleKind;
bool differentCodec = existingSubtitle.Codec != incomingSubtitle.Codec;
bool differentLanguage = existingSubtitle.Language != incomingSubtitle.Language;
bool differentFlags = existingSubtitle.Default != incomingSubtitle.Default ||
existingSubtitle.Forced != incomingSubtitle.Forced ||
existingSubtitle.SDH != incomingSubtitle.SDH;
if (differentKind || differentCodec || differentLanguage || differentFlags)
{
existingSubtitle.IsExtracted = false;
existingSubtitle.Path = incomingSubtitle.Path;
}
else
{
// only re-extract if path changed
// this probably won't ever happen?
bool differentPath = existingSubtitle.Path != incomingSubtitle.Path;
if (!string.IsNullOrEmpty(incomingSubtitle.Path) && differentPath)
{
existingSubtitle.IsExtracted = false;
existingSubtitle.Path = incomingSubtitle.Path;
}
}
}
existingSubtitle.Default = incomingSubtitle.Default;
existingSubtitle.Forced = incomingSubtitle.Forced;
existingSubtitle.SDH = incomingSubtitle.SDH;
existingSubtitle.Language = incomingSubtitle.Language;
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
existingSubtitle.Codec = incomingSubtitle.Codec;
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
existingSubtitle.Path = incomingSubtitle.Path;
dbContext.Entry(existingSubtitle).State = EntityState.Modified;
}

3
ErsatzTV/Services/WorkerService.cs

@ -117,6 +117,9 @@ public class WorkerService : BackgroundService @@ -117,6 +117,9 @@ public class WorkerService : BackgroundService
case ExtractEmbeddedSubtitles extractEmbeddedSubtitles:
await mediator.Send(extractEmbeddedSubtitles, stoppingToken);
break;
case ExtractEmbeddedShowSubtitles extractEmbeddedShowSubtitles:
await mediator.Send(extractEmbeddedShowSubtitles, stoppingToken);
break;
#endif
case ReleaseMemory aggressivelyReleaseMemory:
await mediator.Send(aggressivelyReleaseMemory, stoppingToken);

Loading…
Cancel
Save