Browse Source

add download media sample button to playback troubleshooting (#2709)

* add download media sample button to playback troubleshooting

* fixes
pull/2689/head
Jason Dove 3 weeks ago committed by GitHub
parent
commit
a1f9b86fc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 42
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  3. 3
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs
  4. 169
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs
  5. 193
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  6. 3
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs
  7. 167
      ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs
  8. 12
      ErsatzTV.Core/Extensions/MediaItemExtensions.cs
  9. 31
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  10. 19
      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/). @@ -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 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
- Add `Download Media Sample` button to playback troubleshooting
- This button will extract up to 30 seconds of the media item and zip it
### Fixed
- 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/). @@ -48,7 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- AMD VAAPI:
- 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
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
### Changed
- 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< @@ -771,7 +771,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
PlayoutItem playoutItem,
CancellationToken cancellationToken)
{
string path = await GetPlayoutItemPath(playoutItem, cancellationToken);
string path = await playoutItem.MediaItem.GetLocalPath(
_plexPathReplacementService,
_jellyfinPathReplacementService,
_embyPathReplacementService,
cancellationToken);
if (_isDebugNoSync)
{
@ -853,42 +857,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -853,42 +857,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
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)
{
DecoEntries decoEntries = _decoSelector.GetDecoEntries(playout, now);

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

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

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

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

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

@ -1,10 +1,8 @@ @@ -1,10 +1,8 @@
using System.IO.Abstractions;
using Dapper;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
@ -39,7 +37,11 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -39,7 +37,11 @@ public class PrepareTroubleshootingPlaybackHandler(
IMediator mediator,
LoggingLevelSwitches loggingLevelSwitches,
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(
PrepareTroubleshootingPlayback request,
@ -105,7 +107,10 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -105,7 +107,10 @@ public class PrepareTroubleshootingPlaybackHandler(
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)
@ -375,80 +380,13 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -375,80 +380,13 @@ public class PrepareTroubleshootingPlaybackHandler(
TvContext dbContext,
PrepareTroubleshootingPlayback request,
CancellationToken cancellationToken) =>
(await MediaItemMustExist(dbContext, request, cancellationToken),
(await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken),
await FFmpegPathMustExist(dbContext, cancellationToken),
await FFprobePathMustExist(dbContext, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
.Apply((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(
TvContext dbContext,
CancellationToken cancellationToken) =>
@ -464,115 +402,4 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -464,115 +402,4 @@ public class PrepareTroubleshootingPlaybackHandler(
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
.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( @@ -120,7 +120,8 @@ public partial class StartTroubleshootingPlaybackHandler(
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;
foreach (GraphicsEngineContext graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext)

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

@ -0,0 +1,167 @@ @@ -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;
}
}

12
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

@ -7,7 +7,9 @@ namespace ErsatzTV.Core.Extensions; @@ -7,7 +7,9 @@ namespace ErsatzTV.Core.Extensions;
public static class MediaItemExtensions
{
public static Option<TimeSpan> GetNonZeroDuration(this MediaItem mediaItem)
extension(MediaItem mediaItem)
{
public Option<TimeSpan> GetNonZeroDuration()
{
Option<TimeSpan> maybeDuration = mediaItem switch
{
@ -24,7 +26,7 @@ public static class MediaItemExtensions @@ -24,7 +26,7 @@ public static class MediaItemExtensions
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)
{
@ -43,7 +45,7 @@ public static class MediaItemExtensions @@ -43,7 +45,7 @@ public static class MediaItemExtensions
return version.Duration;
}
public static MediaVersion GetHeadVersion(this MediaItem mediaItem) =>
public MediaVersion GetHeadVersion() =>
mediaItem switch
{
Movie m => m.MediaVersions.Head(),
@ -57,8 +59,7 @@ public static class MediaItemExtensions @@ -57,8 +59,7 @@ public static class MediaItemExtensions
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
public static async Task<string> GetLocalPath(
this MediaItem mediaItem,
public async Task<string> GetLocalPath(
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
@ -104,4 +105,5 @@ public static class MediaItemExtensions @@ -104,4 +105,5 @@ public static class MediaItemExtensions
_ => path
};
}
}
}

31
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -164,7 +164,13 @@ public class TroubleshootController( @@ -164,7 +164,13 @@ public class TroubleshootController(
Option<string> maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken);
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(
fs,
"application/zip",
@ -173,4 +179,27 @@ public class TroubleshootController( @@ -173,4 +179,27 @@ public class TroubleshootController(
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();
}
}

19
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

@ -22,6 +22,7 @@ @@ -22,6 +22,7 @@
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
Class="ml-6"
@ -30,6 +31,17 @@ @@ -30,6 +31,17 @@
OnClick="@DownloadResults">
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>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@ -64,7 +76,7 @@ @@ -64,7 +76,7 @@
<div class="d-flex">
<MudText>Stream Selector</MudText>
</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)
{
<MudSelectItem T="string" Value="@selector">@selector</MudSelectItem>
@ -402,6 +414,11 @@ @@ -402,6 +414,11 @@
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)
{
await InvokeAsync(async () => { await _logsField.SetText(string.Empty); });

Loading…
Cancel
Save