using System.IO.Abstractions; using System.IO.Compression; using CliWrap; using CliWrap.Buffered; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Troubleshooting; public class ArchiveMediaSampleHandler( IDbContextFactory dbContextFactory, IPlexPathReplacementService plexPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService, IEmbyPathReplacementService embyPathReplacementService, IFileSystem fileSystem, ILogger logger) : TroubleshootingHandlerBase( plexPathReplacementService, jellyfinPathReplacementService, embyPathReplacementService, fileSystem), IRequestHandler> { public async Task> Handle(ArchiveMediaSample request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Validation> validation = await Validate( dbContext, request, cancellationToken); foreach ((MediaItem mediaItem, string ffmpegPath) in validation.SuccessToSeq()) { Option maybeMediaSample = await GetMediaSample( request, dbContext, mediaItem, ffmpegPath, cancellationToken); foreach (string mediaSample in maybeMediaSample) { return await GetArchive(request, mediaSample, cancellationToken); } } return Option.None; } private async Task> GetArchive( ArchiveMediaSample request, string mediaSample, CancellationToken cancellationToken) { try { string tempFile = Path.GetTempFileName(); await using ZipArchive zipArchive = await ZipFile.OpenAsync( tempFile, ZipArchiveMode.Update, cancellationToken); string fileName = Path.GetFileName(mediaSample); await zipArchive.CreateEntryFromFileAsync(mediaSample, fileName, cancellationToken); return tempFile; } catch (Exception ex) { logger.LogWarning(ex, "Failed to archive media sample for media item {MediaItemId}", request.MediaItemId); } return Option.None; } private async Task> GetMediaSample( ArchiveMediaSample request, TvContext dbContext, MediaItem mediaItem, string ffmpegPath, CancellationToken cancellationToken) { try { string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken); string extension = Path.GetExtension(mediaItemPath); if (string.IsNullOrWhiteSpace(extension)) { // this can help with remote servers (e.g. mediaItemPath is http://localhost/whatever) extension = Path.GetExtension(await GetLocalPath(mediaItem, cancellationToken)); if (string.IsNullOrWhiteSpace(extension)) { // fall back to mkv when extension is otherwise unknown extension = "mkv"; } } string tempPath = Path.GetTempPath(); string fileName = Path.ChangeExtension(Guid.NewGuid().ToString(), extension); string outputPath = Path.Combine(tempPath, fileName); List arguments = [ "-nostdin", "-i", mediaItemPath, "-t", "30", "-map", "0", "-c", "copy", outputPath ]; using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); logger.LogDebug("media sample arguments {Arguments}", arguments); BufferedCommandResult result = await Cli.Wrap(ffmpegPath) .WithArguments(arguments) .WithWorkingDirectory(FileSystemLayout.FontsCacheFolder) .WithStandardErrorPipe(PipeTarget.Null) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync(linkedTokenSource.Token); if (result.IsSuccess) { return outputPath; } logger.LogWarning( "Failed to extract media sample for media item {MediaItemId} - exit code {ExitCode}", request.MediaItemId, result.ExitCode); } catch (Exception ex) { logger.LogWarning(ex, "Failed to extract media sample for media item {MediaItemId}", request.MediaItemId); } return Option.None; } private static async Task>> Validate( TvContext dbContext, ArchiveMediaSample request, CancellationToken cancellationToken) => (await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken), await FFmpegPathMustExist(dbContext, cancellationToken)) .Apply((mediaItem, ffmpegPath) => Tuple(mediaItem, ffmpegPath)); }