Browse Source

add download media sample button to playback troubleshooting

pull/2709/head
Jason Dove 8 months ago
parent
commit
70886953ac
No known key found for this signature in database
  1. 4
      CHANGELOG.md
  2. 42
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  3. 3
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs
  4. 157
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs
  5. 193
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  6. 3
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs
  7. 166
      ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs
  8. 170
      ErsatzTV.Core/Extensions/MediaItemExtensions.cs
  9. 17
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  10. 35
      ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

4
CHANGELOG.md

@ -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

42
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

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

3
ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Troubleshooting;
public record ArchiveMediaSample(int MediaItemId) : IRequest<Option<string>>;

157
ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs

@ -0,0 +1,157 @@
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>>
{
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)
{
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<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);
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));
}

193
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

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

3
ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs

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

166
ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs

@ -0,0 +1,166 @@
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)
.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;
}
}

170
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

@ -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) public TimeSpan GetDurationForPlayout()
{
if (mediaItem is Image image)
{ {
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds); if (mediaItem is Image image)
} {
return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds);
}
MediaVersion version = mediaItem.GetHeadVersion(); MediaVersion version = mediaItem.GetHeadVersion();
if (mediaItem is RemoteStream remoteStream) if (mediaItem is RemoteStream remoteStream)
{ {
return version.Duration == TimeSpan.Zero && remoteStream.Duration.HasValue return version.Duration == TimeSpan.Zero && remoteStream.Duration.HasValue
? remoteStream.Duration.Value ? remoteStream.Duration.Value
: version.Duration; : 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(
this MediaItem mediaItem,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
CancellationToken cancellationToken,
bool log = true)
{
MediaVersion version = mediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head(); MediaFile file = version.MediaFiles.Head();
string path = file.Path; string path = file.Path;
return mediaItem switch return mediaItem switch
{ {
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath( PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
plexMovie.LibraryPathId, plexMovie.LibraryPathId,
path, path,
cancellationToken, cancellationToken,
log), log),
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath( PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
plexEpisode.LibraryPathId, plexEpisode.LibraryPathId,
path, path,
cancellationToken, cancellationToken,
log), log),
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath( JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinMovie.LibraryPathId, jellyfinMovie.LibraryPathId,
path, path,
cancellationToken, cancellationToken,
log), log),
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath( JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinEpisode.LibraryPathId, jellyfinEpisode.LibraryPathId,
path, path,
cancellationToken, cancellationToken,
log), log),
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath( EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
embyMovie.LibraryPathId, embyMovie.LibraryPathId,
path, path,
cancellationToken, cancellationToken,
log), log),
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath( EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
embyEpisode.LibraryPathId, embyEpisode.LibraryPathId,
path, path,
cancellationToken, cancellationToken,
log), log),
_ => path _ => path
}; };
}
} }
} }

17
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -173,4 +173,21 @@ 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)
{
FileStream fs = System.IO.File.OpenRead(archivePath);
return File(
fs,
"application/zip",
$"ersatztv-media-sample-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip");
}
return NotFound();
}
} }

35
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

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

Loading…
Cancel
Save