add download media sample button to playback troubleshooting (#2709)
* add download media sample button to playback troubleshooting * fixes
This commit is contained in:
+3
-1
@@ -30,6 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
|
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
|
||||||
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
|
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
|
||||||
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
|
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
|
||||||
|
- Add `Download Media Sample` button to playback troubleshooting
|
||||||
|
- This button will extract up to 30 seconds of the media item and zip it
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fix startup on systems unsupported by NvEncSharp
|
- Fix startup on systems unsupported by NvEncSharp
|
||||||
@@ -48,7 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
- AMD VAAPI:
|
- AMD VAAPI:
|
||||||
- work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
|
- work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
|
||||||
- fix green padding when encoding h264 using main profile
|
- fix green padding when encoding h264 using main profile
|
||||||
|
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- No longer round framerate to nearest integer when normalizing framerate
|
- No longer round framerate to nearest integer when normalizing framerate
|
||||||
|
|||||||
+5
-37
@@ -771,7 +771,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
PlayoutItem playoutItem,
|
PlayoutItem playoutItem,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
string path = await GetPlayoutItemPath(playoutItem, cancellationToken);
|
string path = await playoutItem.MediaItem.GetLocalPath(
|
||||||
|
_plexPathReplacementService,
|
||||||
|
_jellyfinPathReplacementService,
|
||||||
|
_embyPathReplacementService,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
if (_isDebugNoSync)
|
if (_isDebugNoSync)
|
||||||
{
|
{
|
||||||
@@ -853,42 +857,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
|||||||
return new PlayoutItemDoesNotExistOnDisk(path);
|
return new PlayoutItemDoesNotExistOnDisk(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
|
|
||||||
MediaFile file = version.MediaFiles.Head();
|
|
||||||
|
|
||||||
string path = file.Path;
|
|
||||||
return playoutItem.MediaItem switch
|
|
||||||
{
|
|
||||||
PlexMovie plexMovie => await _plexPathReplacementService.GetReplacementPlexPath(
|
|
||||||
plexMovie.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
|
|
||||||
plexEpisode.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
|
||||||
jellyfinMovie.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
|
||||||
jellyfinEpisode.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath(
|
|
||||||
embyMovie.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath(
|
|
||||||
embyEpisode.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
_ => path
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now)
|
private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
DecoEntries decoEntries = _decoSelector.GetDecoEntries(playout, now);
|
DecoEntries decoEntries = _decoSelector.GetDecoEntries(playout, now);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ErsatzTV.Application.Troubleshooting;
|
||||||
|
|
||||||
|
public record ArchiveMediaSample(int MediaItemId) : IRequest<Option<string>>;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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<TvContext> dbContextFactory,
|
||||||
|
IPlexPathReplacementService plexPathReplacementService,
|
||||||
|
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||||
|
IEmbyPathReplacementService embyPathReplacementService,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
ILogger<ArchiveMediaSampleHandler> logger)
|
||||||
|
: TroubleshootingHandlerBase(
|
||||||
|
plexPathReplacementService,
|
||||||
|
jellyfinPathReplacementService,
|
||||||
|
embyPathReplacementService,
|
||||||
|
fileSystem), IRequestHandler<ArchiveMediaSample, Option<string>>
|
||||||
|
{
|
||||||
|
private readonly IFileSystem _fileSystem = fileSystem;
|
||||||
|
|
||||||
|
public async Task<Option<string>> Handle(ArchiveMediaSample request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
Validation<BaseError, Tuple<MediaItem, string>> validation = await Validate(
|
||||||
|
dbContext,
|
||||||
|
request,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
foreach ((MediaItem mediaItem, string ffmpegPath) in validation.SuccessToSeq())
|
||||||
|
{
|
||||||
|
Option<string> maybeMediaSample = await GetMediaSample(
|
||||||
|
request,
|
||||||
|
dbContext,
|
||||||
|
mediaItem,
|
||||||
|
ffmpegPath,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
foreach (string mediaSample in maybeMediaSample)
|
||||||
|
{
|
||||||
|
return await GetArchive(request, mediaSample, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<string>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Option<string>> GetArchive(
|
||||||
|
ArchiveMediaSample request,
|
||||||
|
string mediaSample,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string tempFile = Path.GetTempFileName();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
_fileSystem.File.Delete(tempFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<string>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Option<string>> GetMediaSample(
|
||||||
|
ArchiveMediaSample request,
|
||||||
|
TvContext dbContext,
|
||||||
|
MediaItem mediaItem,
|
||||||
|
string ffmpegPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken);
|
||||||
|
if (string.IsNullOrEmpty(mediaItemPath))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Media item {MediaItemId} does not exist on disk; cannot extract media sample.",
|
||||||
|
mediaItem.Id);
|
||||||
|
|
||||||
|
return Option<string>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string> 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<string>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Validation<BaseError, Tuple<MediaItem, string>>> Validate(
|
||||||
|
TvContext dbContext,
|
||||||
|
ArchiveMediaSample request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
(await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken),
|
||||||
|
await FFmpegPathMustExist(dbContext, cancellationToken))
|
||||||
|
.Apply((mediaItem, ffmpegPath) => Tuple(mediaItem, ffmpegPath));
|
||||||
|
}
|
||||||
+10
-183
@@ -1,10 +1,8 @@
|
|||||||
using System.IO.Abstractions;
|
using System.IO.Abstractions;
|
||||||
using Dapper;
|
|
||||||
using ErsatzTV.Application.Streaming;
|
using ErsatzTV.Application.Streaming;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Domain.Filler;
|
using ErsatzTV.Core.Domain.Filler;
|
||||||
using ErsatzTV.Core.Errors;
|
|
||||||
using ErsatzTV.Core.Extensions;
|
using ErsatzTV.Core.Extensions;
|
||||||
using ErsatzTV.Core.FFmpeg;
|
using ErsatzTV.Core.FFmpeg;
|
||||||
using ErsatzTV.Core.Interfaces.Emby;
|
using ErsatzTV.Core.Interfaces.Emby;
|
||||||
@@ -39,7 +37,11 @@ public class PrepareTroubleshootingPlaybackHandler(
|
|||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
LoggingLevelSwitches loggingLevelSwitches,
|
LoggingLevelSwitches loggingLevelSwitches,
|
||||||
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
|
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
|
||||||
: IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, PlayoutItemResult>>
|
: TroubleshootingHandlerBase(
|
||||||
|
plexPathReplacementService,
|
||||||
|
jellyfinPathReplacementService,
|
||||||
|
embyPathReplacementService,
|
||||||
|
fileSystem), IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, PlayoutItemResult>>
|
||||||
{
|
{
|
||||||
public async Task<Either<BaseError, PlayoutItemResult>> Handle(
|
public async Task<Either<BaseError, PlayoutItemResult>> Handle(
|
||||||
PrepareTroubleshootingPlayback request,
|
PrepareTroubleshootingPlayback request,
|
||||||
@@ -105,7 +107,10 @@ public class PrepareTroubleshootingPlaybackHandler(
|
|||||||
entityLocker.UnlockTroubleshootingPlayback();
|
entityLocker.UnlockTroubleshootingPlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Map(model => new PlayoutItemResult(model.Process, model.GraphicsEngineContext, model.MediaItemId));
|
return result.Map(model => new PlayoutItemResult(
|
||||||
|
model.Process,
|
||||||
|
model.GraphicsEngineContext,
|
||||||
|
model.MediaItemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maybeChannel.IsNone)
|
if (maybeChannel.IsNone)
|
||||||
@@ -375,80 +380,13 @@ public class PrepareTroubleshootingPlaybackHandler(
|
|||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
PrepareTroubleshootingPlayback request,
|
PrepareTroubleshootingPlayback request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
(await MediaItemMustExist(dbContext, request, cancellationToken),
|
(await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken),
|
||||||
await FFmpegPathMustExist(dbContext, cancellationToken),
|
await FFmpegPathMustExist(dbContext, cancellationToken),
|
||||||
await FFprobePathMustExist(dbContext, cancellationToken),
|
await FFprobePathMustExist(dbContext, cancellationToken),
|
||||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
|
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
|
||||||
.Apply((mediaItem, ffmpegPath, ffprobePath, ffmpegProfile) =>
|
.Apply((mediaItem, ffmpegPath, ffprobePath, ffmpegProfile) =>
|
||||||
Tuple(mediaItem, ffmpegPath, ffprobePath, ffmpegProfile));
|
Tuple(mediaItem, ffmpegPath, ffprobePath, ffmpegProfile));
|
||||||
|
|
||||||
private static async Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
|
|
||||||
TvContext dbContext,
|
|
||||||
PrepareTroubleshootingPlayback request,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
await dbContext.MediaItems
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
|
||||||
.ThenInclude(em => em.Subtitles)
|
|
||||||
.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).Season)
|
|
||||||
.ThenInclude(s => s.Show)
|
|
||||||
.ThenInclude(s => s.ShowMetadata)
|
|
||||||
.Include(mi => (mi as Movie).MovieMetadata)
|
|
||||||
.ThenInclude(mm => mm.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 MusicVideo).MusicVideoMetadata)
|
|
||||||
.ThenInclude(mvm => mvm.Subtitles)
|
|
||||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
||||||
.ThenInclude(mvm => mvm.Artists)
|
|
||||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
||||||
.ThenInclude(mvm => mvm.Studios)
|
|
||||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
||||||
.ThenInclude(mvm => mvm.Directors)
|
|
||||||
.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).Artist)
|
|
||||||
.ThenInclude(mv => mv.ArtistMetadata)
|
|
||||||
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
|
||||||
.ThenInclude(ovm => ovm.Subtitles)
|
|
||||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
|
||||||
.ThenInclude(ov => ov.MediaFiles)
|
|
||||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
|
||||||
.ThenInclude(ov => ov.Streams)
|
|
||||||
.Include(mi => (mi as Song).MediaVersions)
|
|
||||||
.ThenInclude(mv => mv.MediaFiles)
|
|
||||||
.Include(mi => (mi as Song).MediaVersions)
|
|
||||||
.ThenInclude(mv => mv.Streams)
|
|
||||||
.Include(mi => (mi as Song).SongMetadata)
|
|
||||||
.ThenInclude(sm => sm.Artwork)
|
|
||||||
.Include(mi => (mi as Image).MediaVersions)
|
|
||||||
.ThenInclude(mv => mv.MediaFiles)
|
|
||||||
.Include(mi => (mi as Image).MediaVersions)
|
|
||||||
.ThenInclude(mv => mv.Streams)
|
|
||||||
.Include(mi => (mi as Image).ImageMetadata)
|
|
||||||
.Include(mi => (mi as RemoteStream).MediaVersions)
|
|
||||||
.ThenInclude(mv => mv.MediaFiles)
|
|
||||||
.Include(mi => (mi as RemoteStream).MediaVersions)
|
|
||||||
.ThenInclude(mv => mv.Streams)
|
|
||||||
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
|
|
||||||
.SelectOneAsync(mi => mi.Id, mi => mi.Id == request.MediaItemId, cancellationToken)
|
|
||||||
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
|
|
||||||
|
|
||||||
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 Task<Validation<BaseError, string>> FFprobePathMustExist(
|
private static Task<Validation<BaseError, string>> FFprobePathMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
@@ -464,115 +402,4 @@ public class PrepareTroubleshootingPlaybackHandler(
|
|||||||
.Include(p => p.Resolution)
|
.Include(p => p.Resolution)
|
||||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
||||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
||||||
|
|
||||||
private async Task<string> GetMediaItemPath(
|
|
||||||
TvContext dbContext,
|
|
||||||
MediaItem mediaItem,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
string path = await GetLocalPath(mediaItem, cancellationToken);
|
|
||||||
|
|
||||||
// check filesystem first
|
|
||||||
if (fileSystem.File.Exists(path))
|
|
||||||
{
|
|
||||||
if (mediaItem is RemoteStream remoteStream)
|
|
||||||
{
|
|
||||||
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
|
|
||||||
? remoteStream.Url
|
|
||||||
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// attempt to remotely stream plex
|
|
||||||
MediaFile file = mediaItem.GetHeadVersion().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)
|
|
||||||
{
|
|
||||||
logger.LogDebug(
|
|
||||||
"Attempting to stream Plex file {PlexFileName} using key {PlexKey}",
|
|
||||||
pmf.Path,
|
|
||||||
pmf.Key);
|
|
||||||
|
|
||||||
return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// attempt to remotely stream jellyfin
|
|
||||||
Option<string> jellyfinItemId = mediaItem switch
|
|
||||||
{
|
|
||||||
JellyfinEpisode e => e.ItemId,
|
|
||||||
JellyfinMovie m => m.ItemId,
|
|
||||||
_ => None
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (string itemId in jellyfinItemId)
|
|
||||||
{
|
|
||||||
return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// attempt to remotely stream emby
|
|
||||||
Option<string> embyItemId = mediaItem switch
|
|
||||||
{
|
|
||||||
EmbyEpisode e => e.ItemId,
|
|
||||||
EmbyMovie m => m.ItemId,
|
|
||||||
_ => None
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (string itemId in embyItemId)
|
|
||||||
{
|
|
||||||
return $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> GetLocalPath(MediaItem mediaItem, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
MediaVersion version = mediaItem.GetHeadVersion();
|
|
||||||
MediaFile file = version.MediaFiles.Head();
|
|
||||||
|
|
||||||
string path = file.Path;
|
|
||||||
return mediaItem switch
|
|
||||||
{
|
|
||||||
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
|
|
||||||
plexMovie.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
|
|
||||||
plexEpisode.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
|
||||||
jellyfinMovie.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
|
||||||
jellyfinEpisode.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
|
|
||||||
embyMovie.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
|
|
||||||
embyEpisode.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken),
|
|
||||||
_ => path
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -120,7 +120,8 @@ public partial class StartTroubleshootingPlaybackHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||||
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token);
|
||||||
|
|
||||||
Command processWithPipe = request.PlayoutItemResult.Process;
|
Command processWithPipe = request.PlayoutItemResult.Process;
|
||||||
foreach (GraphicsEngineContext graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext)
|
foreach (GraphicsEngineContext graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using System.IO.Abstractions;
|
||||||
|
using Dapper;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
|
using ErsatzTV.Core.Extensions;
|
||||||
|
using ErsatzTV.Core.Interfaces.Emby;
|
||||||
|
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||||
|
using ErsatzTV.Core.Interfaces.Plex;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Troubleshooting;
|
||||||
|
|
||||||
|
public abstract class TroubleshootingHandlerBase(
|
||||||
|
IPlexPathReplacementService plexPathReplacementService,
|
||||||
|
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||||
|
IEmbyPathReplacementService embyPathReplacementService,
|
||||||
|
IFileSystem fileSystem)
|
||||||
|
{
|
||||||
|
protected static async Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
|
||||||
|
TvContext dbContext,
|
||||||
|
int mediaItemId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await dbContext.MediaItems
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||||
|
.ThenInclude(em => em.Subtitles)
|
||||||
|
.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).Season)
|
||||||
|
.ThenInclude(s => s.Show)
|
||||||
|
.ThenInclude(s => s.ShowMetadata)
|
||||||
|
.Include(mi => (mi as Movie).MovieMetadata)
|
||||||
|
.ThenInclude(mm => mm.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 MusicVideo).MusicVideoMetadata)
|
||||||
|
.ThenInclude(mvm => mvm.Subtitles)
|
||||||
|
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||||
|
.ThenInclude(mvm => mvm.Artists)
|
||||||
|
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||||
|
.ThenInclude(mvm => mvm.Studios)
|
||||||
|
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||||
|
.ThenInclude(mvm => mvm.Directors)
|
||||||
|
.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).Artist)
|
||||||
|
.ThenInclude(mv => mv.ArtistMetadata)
|
||||||
|
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||||
|
.ThenInclude(ovm => ovm.Subtitles)
|
||||||
|
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||||
|
.ThenInclude(ov => ov.MediaFiles)
|
||||||
|
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||||
|
.ThenInclude(ov => ov.Streams)
|
||||||
|
.Include(mi => (mi as Song).MediaVersions)
|
||||||
|
.ThenInclude(mv => mv.MediaFiles)
|
||||||
|
.Include(mi => (mi as Song).MediaVersions)
|
||||||
|
.ThenInclude(mv => mv.Streams)
|
||||||
|
.Include(mi => (mi as Song).SongMetadata)
|
||||||
|
.ThenInclude(sm => sm.Artwork)
|
||||||
|
.Include(mi => (mi as Image).MediaVersions)
|
||||||
|
.ThenInclude(mv => mv.MediaFiles)
|
||||||
|
.Include(mi => (mi as Image).MediaVersions)
|
||||||
|
.ThenInclude(mv => mv.Streams)
|
||||||
|
.Include(mi => (mi as Image).ImageMetadata)
|
||||||
|
.Include(mi => (mi as RemoteStream).MediaVersions)
|
||||||
|
.ThenInclude(mv => mv.MediaFiles)
|
||||||
|
.Include(mi => (mi as RemoteStream).MediaVersions)
|
||||||
|
.ThenInclude(mv => mv.Streams)
|
||||||
|
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.SingleOrDefaultAsync(mi => mi.Id == mediaItemId, cancellationToken)
|
||||||
|
.Map(Optional)
|
||||||
|
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
|
||||||
|
|
||||||
|
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 Task<string> GetLocalPath(MediaItem mediaItem, CancellationToken cancellationToken) =>
|
||||||
|
mediaItem.GetLocalPath(
|
||||||
|
plexPathReplacementService,
|
||||||
|
jellyfinPathReplacementService,
|
||||||
|
embyPathReplacementService,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
protected async Task<string> GetMediaItemPath(
|
||||||
|
TvContext dbContext,
|
||||||
|
MediaItem mediaItem,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string path = await GetLocalPath(mediaItem, cancellationToken);
|
||||||
|
|
||||||
|
// check filesystem first
|
||||||
|
if (fileSystem.File.Exists(path))
|
||||||
|
{
|
||||||
|
if (mediaItem is RemoteStream remoteStream)
|
||||||
|
{
|
||||||
|
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
|
||||||
|
? remoteStream.Url
|
||||||
|
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to remotely stream plex
|
||||||
|
MediaFile file = mediaItem.GetHeadVersion().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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to remotely stream jellyfin
|
||||||
|
Option<string> jellyfinItemId = mediaItem switch
|
||||||
|
{
|
||||||
|
JellyfinEpisode e => e.ItemId,
|
||||||
|
JellyfinMovie m => m.ItemId,
|
||||||
|
_ => None
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (string itemId in jellyfinItemId)
|
||||||
|
{
|
||||||
|
return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to remotely stream emby
|
||||||
|
Option<string> embyItemId = mediaItem switch
|
||||||
|
{
|
||||||
|
EmbyEpisode e => e.ItemId,
|
||||||
|
EmbyMovie m => m.ItemId,
|
||||||
|
_ => None
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (string itemId in embyItemId)
|
||||||
|
{
|
||||||
|
return $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,101 +7,103 @@ namespace ErsatzTV.Core.Extensions;
|
|||||||
|
|
||||||
public static class MediaItemExtensions
|
public static class MediaItemExtensions
|
||||||
{
|
{
|
||||||
public static Option<TimeSpan> GetNonZeroDuration(this MediaItem mediaItem)
|
extension(MediaItem mediaItem)
|
||||||
{
|
{
|
||||||
Option<TimeSpan> maybeDuration = mediaItem switch
|
public Option<TimeSpan> GetNonZeroDuration()
|
||||||
{
|
{
|
||||||
Movie m => m.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
Option<TimeSpan> maybeDuration = mediaItem switch
|
||||||
Episode e => e.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
{
|
||||||
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
Movie m => m.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
||||||
OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
Episode e => e.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
||||||
Song s => s.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
||||||
ChapterMediaItem c => c.MediaVersion.Duration,
|
OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
||||||
_ => None
|
Song s => s.MediaVersions.HeadOrNone().Map(v => v.Duration),
|
||||||
};
|
ChapterMediaItem c => c.MediaVersion.Duration,
|
||||||
|
_ => None
|
||||||
|
};
|
||||||
|
|
||||||
// zero duration is invalid, so return none in that case
|
// zero duration is invalid, so return none in that case
|
||||||
return maybeDuration.Any(duration => duration == TimeSpan.Zero) ? Option<TimeSpan>.None : maybeDuration;
|
return maybeDuration.Any(duration => duration == TimeSpan.Zero) ? Option<TimeSpan>.None : maybeDuration;
|
||||||
}
|
|
||||||
|
|
||||||
public static TimeSpan GetDurationForPlayout(this MediaItem mediaItem)
|
|
||||||
{
|
|
||||||
if (mediaItem is Image image)
|
|
||||||
{
|
|
||||||
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaVersion version = mediaItem.GetHeadVersion();
|
public TimeSpan GetDurationForPlayout()
|
||||||
|
|
||||||
if (mediaItem is RemoteStream remoteStream)
|
|
||||||
{
|
{
|
||||||
return version.Duration == TimeSpan.Zero && remoteStream.Duration.HasValue
|
if (mediaItem is Image image)
|
||||||
? remoteStream.Duration.Value
|
{
|
||||||
: version.Duration;
|
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaVersion version = mediaItem.GetHeadVersion();
|
||||||
|
|
||||||
|
if (mediaItem is RemoteStream remoteStream)
|
||||||
|
{
|
||||||
|
return version.Duration == TimeSpan.Zero && remoteStream.Duration.HasValue
|
||||||
|
? remoteStream.Duration.Value
|
||||||
|
: version.Duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return version.Duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return version.Duration;
|
public MediaVersion GetHeadVersion() =>
|
||||||
}
|
mediaItem switch
|
||||||
|
{
|
||||||
|
Movie m => m.MediaVersions.Head(),
|
||||||
|
Episode e => e.MediaVersions.Head(),
|
||||||
|
MusicVideo mv => mv.MediaVersions.Head(),
|
||||||
|
OtherVideo ov => ov.MediaVersions.Head(),
|
||||||
|
Song s => s.MediaVersions.Head(),
|
||||||
|
Image i => i.MediaVersions.Head(),
|
||||||
|
RemoteStream rs => rs.MediaVersions.Head(),
|
||||||
|
ChapterMediaItem c => c.MediaVersion,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||||
|
};
|
||||||
|
|
||||||
public static MediaVersion GetHeadVersion(this MediaItem mediaItem) =>
|
public async Task<string> GetLocalPath(
|
||||||
mediaItem switch
|
IPlexPathReplacementService plexPathReplacementService,
|
||||||
|
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||||
|
IEmbyPathReplacementService embyPathReplacementService,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
bool log = true)
|
||||||
{
|
{
|
||||||
Movie m => m.MediaVersions.Head(),
|
MediaVersion version = mediaItem.GetHeadVersion();
|
||||||
Episode e => e.MediaVersions.Head(),
|
|
||||||
MusicVideo mv => mv.MediaVersions.Head(),
|
|
||||||
OtherVideo ov => ov.MediaVersions.Head(),
|
|
||||||
Song s => s.MediaVersions.Head(),
|
|
||||||
Image i => i.MediaVersions.Head(),
|
|
||||||
RemoteStream rs => rs.MediaVersions.Head(),
|
|
||||||
ChapterMediaItem c => c.MediaVersion,
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
|
||||||
};
|
|
||||||
|
|
||||||
public static async Task<string> GetLocalPath(
|
MediaFile file = version.MediaFiles.Head();
|
||||||
this MediaItem mediaItem,
|
string path = file.Path;
|
||||||
IPlexPathReplacementService plexPathReplacementService,
|
return mediaItem switch
|
||||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
{
|
||||||
IEmbyPathReplacementService embyPathReplacementService,
|
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
|
||||||
CancellationToken cancellationToken,
|
plexMovie.LibraryPathId,
|
||||||
bool log = true)
|
path,
|
||||||
{
|
cancellationToken,
|
||||||
MediaVersion version = mediaItem.GetHeadVersion();
|
log),
|
||||||
|
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
|
||||||
MediaFile file = version.MediaFiles.Head();
|
plexEpisode.LibraryPathId,
|
||||||
string path = file.Path;
|
path,
|
||||||
return mediaItem switch
|
cancellationToken,
|
||||||
{
|
log),
|
||||||
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
|
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||||
plexMovie.LibraryPathId,
|
jellyfinMovie.LibraryPathId,
|
||||||
path,
|
path,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
log),
|
log),
|
||||||
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
|
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||||
plexEpisode.LibraryPathId,
|
jellyfinEpisode.LibraryPathId,
|
||||||
path,
|
path,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
log),
|
log),
|
||||||
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
|
||||||
jellyfinMovie.LibraryPathId,
|
embyMovie.LibraryPathId,
|
||||||
path,
|
path,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
log),
|
log),
|
||||||
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
|
||||||
jellyfinEpisode.LibraryPathId,
|
embyEpisode.LibraryPathId,
|
||||||
path,
|
path,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
log),
|
log),
|
||||||
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
|
_ => path
|
||||||
embyMovie.LibraryPathId,
|
};
|
||||||
path,
|
}
|
||||||
cancellationToken,
|
|
||||||
log),
|
|
||||||
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
|
|
||||||
embyEpisode.LibraryPathId,
|
|
||||||
path,
|
|
||||||
cancellationToken,
|
|
||||||
log),
|
|
||||||
_ => path
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,13 @@ public class TroubleshootController(
|
|||||||
Option<string> maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken);
|
Option<string> maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken);
|
||||||
foreach (string archivePath in maybeArchivePath)
|
foreach (string archivePath in maybeArchivePath)
|
||||||
{
|
{
|
||||||
FileStream fs = System.IO.File.OpenRead(archivePath);
|
var fs = new FileStream(
|
||||||
|
archivePath,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.Read,
|
||||||
|
4096,
|
||||||
|
FileOptions.DeleteOnClose);
|
||||||
return File(
|
return File(
|
||||||
fs,
|
fs,
|
||||||
"application/zip",
|
"application/zip",
|
||||||
@@ -173,4 +179,27 @@ public class TroubleshootController(
|
|||||||
|
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpHead("api/troubleshoot/playback/sample/{mediaItemId:int}")]
|
||||||
|
[HttpGet("api/troubleshoot/playback/sample/{mediaItemId:int}")]
|
||||||
|
public async Task<IActionResult> TroubleshootPlaybackSample(int mediaItemId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<string> maybeArchivePath = await mediator.Send(new ArchiveMediaSample(mediaItemId), cancellationToken);
|
||||||
|
foreach (string archivePath in maybeArchivePath)
|
||||||
|
{
|
||||||
|
var fs = new FileStream(
|
||||||
|
archivePath,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.Read,
|
||||||
|
4096,
|
||||||
|
FileOptions.DeleteOnClose);
|
||||||
|
return File(
|
||||||
|
fs,
|
||||||
|
"application/zip",
|
||||||
|
$"ersatztv-media-sample-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,26 @@
|
|||||||
|
|
||||||
<MudForm Style="max-height: 100%">
|
<MudForm Style="max-height: 100%">
|
||||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
|
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
|
||||||
<MudButton Variant="Variant.Filled"
|
<div class="d-none d-md-flex">
|
||||||
Color="Color.Secondary"
|
<MudButton Variant="Variant.Filled"
|
||||||
Class="ml-6"
|
Color="Color.Secondary"
|
||||||
StartIcon="@Icons.Material.Filled.Download"
|
Class="ml-6"
|
||||||
Disabled="@(!_hasPlayed || Locker.IsTroubleshootingPlaybackLocked())"
|
StartIcon="@Icons.Material.Filled.Download"
|
||||||
OnClick="@DownloadResults">
|
Disabled="@(!_hasPlayed || Locker.IsTroubleshootingPlaybackLocked())"
|
||||||
Download Results
|
OnClick="@DownloadResults">
|
||||||
</MudButton>
|
Download Results
|
||||||
|
</MudButton>
|
||||||
|
@if (MediaItemId.HasValue)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
Class="ml-6"
|
||||||
|
StartIcon="@Icons.Material.Filled.DownloadForOffline"
|
||||||
|
OnClick="@DownloadSample">
|
||||||
|
Download Media Sample
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||||
@@ -64,7 +76,7 @@
|
|||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<MudText>Stream Selector</MudText>
|
<MudText>Stream Selector</MudText>
|
||||||
</div>
|
</div>
|
||||||
<MudSelect @bind-Value="_streamSelector" For="@(() => _streamSelector)" Clearable="true">
|
<MudSelect @bind-Value="_streamSelector" For="@(() => _streamSelector)" Clearable="true" Disabled="@(_streamSelectors.Count == 0)">
|
||||||
@foreach (string selector in _streamSelectors)
|
@foreach (string selector in _streamSelectors)
|
||||||
{
|
{
|
||||||
<MudSelectItem T="string" Value="@selector">@selector</MudSelectItem>
|
<MudSelectItem T="string" Value="@selector">@selector</MudSelectItem>
|
||||||
@@ -402,6 +414,11 @@
|
|||||||
await JsRuntime.InvokeVoidAsync("window.open", "api/troubleshoot/playback/archive");
|
await JsRuntime.InvokeVoidAsync("window.open", "api/troubleshoot/playback/archive");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task DownloadSample()
|
||||||
|
{
|
||||||
|
await JsRuntime.InvokeVoidAsync("window.open", $"api/troubleshoot/playback/sample/{MediaItemId}");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result)
|
private async Task HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result)
|
||||||
{
|
{
|
||||||
await InvokeAsync(async () => { await _logsField.SetText(string.Empty); });
|
await InvokeAsync(async () => { await _logsField.SetText(string.Empty); });
|
||||||
|
|||||||
Reference in New Issue
Block a user