Browse Source

add playback troubleshooting tool (#2155)

* support media info for more content types

* add playback troubleshooting page

* reorganize playback troubleshooting

* fix watermarks and delay

* update changelog
pull/2157/head
Jason Dove 1 month ago committed by GitHub
parent
commit
578cdb1e14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/MediaItems/MediaItemInfo.cs
  3. 37
      ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs
  4. 24
      ErsatzTV.Application/Playouts/Mapper.cs
  5. 2
      ErsatzTV.Application/Scheduling/Mapper.cs
  6. 1
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  7. 4
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs
  8. 68
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs
  9. 7
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs
  10. 296
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  11. 5
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs
  12. 33
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs
  13. 8
      ErsatzTV.Core/Errors/UnableToLocateMediaItem.cs
  14. 12
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  15. 1
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  16. 4
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  17. 12
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  18. 6
      ErsatzTV.FFmpeg/FFmpegState.cs
  19. 27
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  20. 3
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  21. 28
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  22. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  23. 86
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  24. 165
      ErsatzTV/Pages/PlaybackTroubleshooting.razor
  25. 101
      ErsatzTV/Pages/Troubleshooting.razor
  26. 2
      ErsatzTV/Pages/Watermarks.razor
  27. 8
      ErsatzTV/Services/FFmpegWorkerService.cs

11
CHANGELOG.md

@ -84,6 +84,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -84,6 +84,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `ETV_MAXIMUM_UPLOAD_MB` environment variable to allow uploading large watermarks
- Default value is 10
- Update ffmpeg health check to link to ErsatzTV-FFmpeg release that contains binaries for win64, linux64, linuxarm64
- Add `Playback Troubleshooting` page
- This tool lets you play specific content without needing a test channel or schedule
- You can specify
- The media item id (found in ETV media info, and ETV movie URLs)
- The ffmpeg profile to use
- The watermark to use (if any)
- Clicking `Play` will play the specified content using the desired settings
- Clicking `Download Results` will generate a zip archive containing:
- The FFmpeg report of the playback attempt
- The media info for the content
- The `Troubleshooting` > `General` output
### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders

1
ErsatzTV.Application/MediaItems/MediaItemInfo.cs

@ -4,6 +4,7 @@ namespace ErsatzTV.Application.MediaItems; @@ -4,6 +4,7 @@ namespace ErsatzTV.Application.MediaItems;
public record MediaItemInfo(
int Id,
string Title,
string Kind,
string LibraryKind,
string ServerName,

37
ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@ -40,10 +41,37 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either< @@ -40,10 +41,37 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
.ThenInclude(mv => mv.Streams)
.Include(i => (i as Episode).EpisodeMetadata)
.ThenInclude(mv => mv.Subtitles)
.Include(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(mv => mv.Subtitles)
.Include(i => (i as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(i => (i as OtherVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => (i as Image).ImageMetadata)
.ThenInclude(mv => mv.Subtitles)
.Include(i => (i as Image).MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(i => (i as Image).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => (i as Song).SongMetadata)
.ThenInclude(mv => mv.Subtitles)
.Include(i => (i as Song).MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(i => (i as Song).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mv => mv.Subtitles)
.Include(i => (i as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(i => (i as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.SelectOneAsync(i => i.Id, i => i.Id == request.Id)
.MapT(Project);
return mediaItem.ToEither(BaseError.New("Unable to locate media item"));
return mediaItem.ToEither<BaseError>(new UnableToLocateMediaItem());
}
catch (Exception ex)
{
@ -53,6 +81,8 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either< @@ -53,6 +81,8 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
private static MediaItemInfo Project(MediaItem mediaItem)
{
string displayTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option<string>.None);
MediaVersion version = mediaItem.GetHeadVersion();
string serverName = mediaItem.LibraryPath.Library.MediaSource switch
@ -68,7 +98,9 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either< @@ -68,7 +98,9 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
List<Subtitle> subtitles = mediaItem switch
{
Movie m => m.MovieMetadata.Map(mm => mm.Subtitles).Flatten().ToList(),
Episode e => e.EpisodeMetadata.Map(mm => mm.Subtitles).Flatten().ToList(),
Episode e => e.EpisodeMetadata.Map(em => em.Subtitles).Flatten().ToList(),
MusicVideo mv => mv.MusicVideoMetadata.Map(mvm => mvm.Subtitles).Flatten().ToList(),
Song s => s.SongMetadata.Map(sm => sm.Subtitles).Flatten().ToList(),
_ => []
};
@ -96,6 +128,7 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either< @@ -96,6 +128,7 @@ public class GetMediaItemInfoHandler : IRequestHandler<GetMediaItemInfo, Either<
return new MediaItemInfo(
mediaItem.Id,
displayTitle,
mediaItem.GetType().Name,
mediaItem.LibraryPath.Library.GetType().Name,
serverName,

24
ErsatzTV.Application/Playouts/Mapper.cs

@ -6,7 +6,7 @@ internal static class Mapper @@ -6,7 +6,7 @@ internal static class Mapper
{
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
GetDisplayTitle(playoutItem),
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset,
playoutItem.FinishOffset,
playoutItem.GetDisplayDuration());
@ -21,9 +21,11 @@ internal static class Mapper @@ -21,9 +21,11 @@ internal static class Mapper
programScheduleAlternate.DaysOfMonth,
programScheduleAlternate.MonthsOfYear);
internal static string GetDisplayTitle(PlayoutItem playoutItem)
internal static string GetDisplayTitle(MediaItem mediaItem, Option<string> maybeChapterTitle)
{
switch (playoutItem.MediaItem)
string chapterTitle = maybeChapterTitle.IfNone(string.Empty);
switch (mediaItem)
{
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
@ -37,9 +39,9 @@ internal static class Mapper @@ -37,9 +39,9 @@ internal static class Mapper
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
var titlesString = $"{string.Join('/', episodeTitles)}";
if (!string.IsNullOrWhiteSpace(playoutItem.ChapterTitle))
if (!string.IsNullOrWhiteSpace(chapterTitle))
{
titlesString += $" ({playoutItem.ChapterTitle})";
titlesString += $" ({chapterTitle})";
}
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
@ -50,16 +52,16 @@ internal static class Mapper @@ -50,16 +52,16 @@ internal static class Mapper
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle)
.Map(s => string.IsNullOrWhiteSpace(chapterTitle)
? s
: $"{s} ({playoutItem.ChapterTitle})")
: $"{s} ({chapterTitle})")
.IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone()
.Map(ovm => ovm.Title ?? string.Empty)
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle)
.Map(s => string.IsNullOrWhiteSpace(chapterTitle)
? s
: $"{s} ({playoutItem.ChapterTitle})")
: $"{s} ({chapterTitle})")
.IfNone("[unknown video]");
case Song s:
string songArtist = s.SongMetadata.HeadOrNone()
@ -67,9 +69,9 @@ internal static class Mapper @@ -67,9 +69,9 @@ internal static class Mapper
.IfNone(string.Empty);
return s.SongMetadata.HeadOrNone()
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle)
.Map(t => string.IsNullOrWhiteSpace(chapterTitle)
? t
: $"{s} ({playoutItem.ChapterTitle})")
: $"{s} ({chapterTitle})")
.IfNone("[unknown song]");
case Image i:
return i.ImageMetadata.HeadOrNone().Map(im => im.Title ?? string.Empty).IfNone("[unknown image]");

2
ErsatzTV.Application/Scheduling/Mapper.cs

@ -143,7 +143,7 @@ internal static class Mapper @@ -143,7 +143,7 @@ internal static class Mapper
internal static PlayoutItemPreviewViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
Playouts.Mapper.GetDisplayTitle(playoutItem),
Playouts.Mapper.GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset.TimeOfDay,
playoutItem.FinishOffset.TimeOfDay,
playoutItem.GetDisplayDuration());

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

@ -327,6 +327,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -327,6 +327,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.PtsOffset,
request.TargetFramerate,
disableWatermarks,
Option<string>.None,
_ => { });
var result = new PlayoutItemProcessModel(process, duration, finish, true);

4
ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs

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

68
ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Application.Troubleshooting;
public class ArchiveTroubleshootingResultsHandler(IMediator mediator, ILocalFileSystem localFileSystem)
: IRequestHandler<ArchiveTroubleshootingResults, Option<string>>
{
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
public async Task<Option<string>> Handle(ArchiveTroubleshootingResults request, CancellationToken cancellationToken)
{
string tempFile = Path.GetTempFileName();
using ZipArchive zipArchive = ZipFile.Open(tempFile, ZipArchiveMode.Update);
string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting");
bool hasReport = false;
foreach (string file in localFileSystem.ListFiles(transcodeFolder))
{
// add to archive
if (Path.GetFileName(file).StartsWith("ffmpeg-", StringComparison.InvariantCultureIgnoreCase))
{
hasReport = true;
zipArchive.CreateEntryFromFile(file, Path.GetFileName(file));
}
}
Either<BaseError, MediaItemInfo> maybeMediaItemInfo = await mediator.Send(new GetMediaItemInfo(request.MediaItemId), cancellationToken);
foreach (MediaItemInfo info in maybeMediaItemInfo.RightToSeq())
{
string infoJson = JsonSerializer.Serialize(info, Options);
string tempMediaInfoFile = Path.GetTempFileName();
await File.WriteAllTextAsync(tempMediaInfoFile, infoJson, cancellationToken);
zipArchive.CreateEntryFromFile(tempMediaInfoFile, "media_info.json");
}
TroubleshootingInfo troubleshootingInfo = await mediator.Send(new GetTroubleshootingInfo(), cancellationToken);
string troubleshootingInfoJson = JsonSerializer.Serialize(
new
{
troubleshootingInfo.Version,
Environment = troubleshootingInfo.Environment.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value),
troubleshootingInfo.Health,
troubleshootingInfo.FFmpegSettings,
troubleshootingInfo.Channels,
troubleshootingInfo.FFmpegProfiles
},
Options);
string tempTroubleshootingInfoFile = Path.GetTempFileName();
await File.WriteAllTextAsync(tempTroubleshootingInfoFile, troubleshootingInfoJson, cancellationToken);
zipArchive.CreateEntryFromFile(tempTroubleshootingInfoFile, "troubleshooting_info.json");
return hasReport ? tempFile : Option<string>.None;
}
}

7
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using CliWrap;
using ErsatzTV.Core;
namespace ErsatzTV.Application.Troubleshooting;
public record PrepareTroubleshootingPlayback(int MediaItemId, int FFmpegProfileId, int WatermarkId)
: IRequest<Either<BaseError, Command>>;

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

@ -0,0 +1,296 @@ @@ -0,0 +1,296 @@
using CliWrap;
using Dapper;
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;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Troubleshooting;
public class PrepareTroubleshootingPlaybackHandler(
IDbContextFactory<TvContext> dbContextFactory,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
: IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, Command>>
{
public async Task<Either<BaseError, Command>> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4),
error => Task.FromResult<Either<BaseError, Command>>(error.Join()));
}
private async Task<Either<BaseError, Command>> GetProcess(
TvContext dbContext,
PrepareTroubleshootingPlayback request,
MediaItem mediaItem,
string ffmpegPath,
string ffprobePath,
FFmpegProfile ffmpegProfile)
{
string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting");
localFileSystem.EnsureFolderExists(transcodeFolder);
localFileSystem.EmptyFolder(transcodeFolder);
ChannelSubtitleMode subtitleMode = ChannelSubtitleMode.None;
MediaVersion version = mediaItem.GetHeadVersion();
string mediaPath = await GetMediaItemPath(dbContext, mediaItem);
if (string.IsNullOrEmpty(mediaPath))
{
logger.LogWarning("Media item {MediaItemId} does not exist on disk; cannot troubleshoot.", mediaItem.Id);
return BaseError.New("Media item does not exist on disk");
}
Option<ChannelWatermark> maybeWatermark = Option<ChannelWatermark>.None;
if (request.WatermarkId > 0)
{
maybeWatermark = await dbContext.ChannelWatermarks
.SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId);
}
DateTimeOffset now = DateTimeOffset.Now;
var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30));
Command process = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
true,
new Channel(Guid.Empty)
{
Number = ".troubleshooting",
FFmpegProfile = ffmpegProfile,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
SubtitleMode = subtitleMode
},
version,
new MediaItemAudioVersion(mediaItem, version),
mediaPath,
mediaPath,
_ => Task.FromResult(new List<Subtitle>()),
string.Empty,
string.Empty,
string.Empty,
subtitleMode,
now,
now + duration,
now,
maybeWatermark,
Option<ChannelWatermark>.None,
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
Option<int>.None,
false,
FillerKind.None,
TimeSpan.Zero,
duration,
0,
None,
false,
transcodeFolder,
_ => { });
return process;
}
private static async Task<Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>>> Validate(
TvContext dbContext,
PrepareTroubleshootingPlayback request) =>
(await MediaItemMustExist(dbContext, request),
await FFmpegPathMustExist(dbContext),
await FFprobePathMustExist(dbContext),
await FFmpegProfileMustExist(dbContext, request))
.Apply((mediaItem, ffmpegPath, ffprobePath, ffmpegProfile) =>
Tuple(mediaItem, ffmpegPath, ffprobePath, ffmpegProfile));
private static async Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
TvContext dbContext,
PrepareTroubleshootingPlayback request)
{
return await dbContext.MediaItems
.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)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == request.MediaItemId)
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
}
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
private static Task<Validation<BaseError, string>> FFprobePathMustExist(TvContext dbContext) =>
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(maybePath => maybePath.ToValidation<BaseError>("FFprobe path does not exist on filesystem"));
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
PrepareTroubleshootingPlayback request) =>
dbContext.FFmpegProfiles
.Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
private async Task<string> GetMediaItemPath(
TvContext dbContext,
MediaItem mediaItem)
{
string path = await GetLocalPath(mediaItem);
// check filesystem first
if (localFileSystem.FileExists(path))
{
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)
{
MediaVersion version = mediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
return mediaItem switch
{
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
plexMovie.LibraryPathId,
path),
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
plexEpisode.LibraryPathId,
path),
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinMovie.LibraryPathId,
path),
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinEpisode.LibraryPathId,
path),
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
embyMovie.LibraryPathId,
path),
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
embyEpisode.LibraryPathId,
path),
_ => path
};
}
}

5
ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using CliWrap;
namespace ErsatzTV.Application.Troubleshooting;
public record StartTroubleshootingPlayback(Command Command) : IRequest, IFFmpegWorkerRequest;

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

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Interfaces.Locking;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Troubleshooting;
public class StartTroubleshootingPlaybackHandler(
IEntityLocker entityLocker,
ILogger<StartTroubleshootingPlaybackHandler> logger)
: IRequestHandler<StartTroubleshootingPlayback>
{
public async Task Handle(StartTroubleshootingPlayback request, CancellationToken cancellationToken)
{
logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.Command.Arguments);
BufferedCommandResult result = await request.Command
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
entityLocker.UnlockTroubleshootingPlayback();
logger.LogInformation("Troubleshooting playback completed with exit code {ExitCode}", result.ExitCode);
foreach (KeyValuePair<string, string> env in request.Command.EnvironmentVariables)
{
logger.LogInformation("{Key} => {Value}", env.Key, env.Value);
}
// TODO: something with the result ???
}
}

8
ErsatzTV.Core/Errors/UnableToLocateMediaItem.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Errors;
public class UnableToLocateMediaItem : BaseError
{
public UnableToLocateMediaItem() : base("Unable to locate media item")
{
}
}

12
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -73,6 +73,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -73,6 +73,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
long ptsOffset,
Option<int> targetFramerate,
bool disableWatermarks,
Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
@ -422,7 +423,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -422,7 +423,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
qsvExtraHardwareFrames,
videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
false,
GetTonemapAlgorithm(playbackSettings));
GetTonemapAlgorithm(playbackSettings),
channel.UniqueId == Guid.Empty);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@ -436,7 +438,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -436,7 +438,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
VaapiDisplayName(hwAccel, vaapiDisplay),
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
FileSystemLayout.FFmpegReportsFolder,
await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder),
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
@ -578,7 +580,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -578,7 +580,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
qsvExtraHardwareFrames,
false,
false,
GetTonemapAlgorithm(playbackSettings));
GetTonemapAlgorithm(playbackSettings),
channel.UniqueId == Guid.Empty);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@ -774,7 +777,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -774,7 +777,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
false,
false,
GetTonemapAlgorithm(playbackSettings));
GetTonemapAlgorithm(playbackSettings),
channel.UniqueId == Guid.Empty);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);

1
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -39,6 +39,7 @@ public interface IFFmpegProcessService @@ -39,6 +39,7 @@ public interface IFFmpegProcessService
long ptsOffset,
Option<int> targetFramerate,
bool disableWatermarks,
Option<string> customReportsFolder,
Action<FFmpegPipeline> pipelineAction);
Task<Command> ForError(

4
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -9,6 +9,7 @@ public interface IEntityLocker @@ -9,6 +9,7 @@ public interface IEntityLocker
event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged;
event EventHandler OnPlexCollectionsChanged;
event EventHandler OnTroubleshootingPlaybackChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId);
@ -33,4 +34,7 @@ public interface IEntityLocker @@ -33,4 +34,7 @@ public interface IEntityLocker
Task<bool> LockPlayout(int playoutId);
Task<bool> UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId);
bool LockTroubleshootingPlayback();
bool UnlockTroubleshootingPlayback();
bool IsTroubleshootingPlaybackLocked();
}

12
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -95,7 +95,8 @@ public class PipelineBuilderBaseTests @@ -95,7 +95,8 @@ public class PipelineBuilderBaseTests
Option<int>.None,
false,
false,
"clip");
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
@ -191,7 +192,8 @@ public class PipelineBuilderBaseTests @@ -191,7 +192,8 @@ public class PipelineBuilderBaseTests
Option<int>.None,
false,
false,
"clip");
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
@ -343,7 +345,8 @@ public class PipelineBuilderBaseTests @@ -343,7 +345,8 @@ public class PipelineBuilderBaseTests
Option<int>.None,
false,
false,
"clip");
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),
@ -433,7 +436,8 @@ public class PipelineBuilderBaseTests @@ -433,7 +436,8 @@ public class PipelineBuilderBaseTests
Option<int>.None,
false,
false,
"clip");
"clip",
false);
var builder = new SoftwarePipelineBuilder(
new DefaultFFmpegCapabilities(),

6
ErsatzTV.FFmpeg/FFmpegState.cs

@ -24,7 +24,8 @@ public record FFmpegState( @@ -24,7 +24,8 @@ public record FFmpegState(
Option<int> MaybeQsvExtraHardwareFrames,
bool IsSongWithProgress,
bool IsHdrTonemap,
string TonemapAlgorithm)
string TonemapAlgorithm,
bool IsTroubleshooting)
{
public int QsvExtraHardwareFrames => MaybeQsvExtraHardwareFrames.IfNone(64);
@ -51,5 +52,6 @@ public record FFmpegState( @@ -51,5 +52,6 @@ public record FFmpegState(
Option<int>.None,
false,
false,
"linear");
"linear",
false);
}

27
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -10,6 +10,7 @@ public class OutputFormatHls : IPipelineStep @@ -10,6 +10,7 @@ public class OutputFormatHls : IPipelineStep
private readonly bool _isFirstTranscode;
private readonly Option<string> _mediaFrameRate;
private readonly bool _oneSecondGop;
private readonly bool _isTroubleshooting;
private readonly string _playlistPath;
private readonly string _segmentTemplate;
@ -19,7 +20,8 @@ public class OutputFormatHls : IPipelineStep @@ -19,7 +20,8 @@ public class OutputFormatHls : IPipelineStep
string segmentTemplate,
string playlistPath,
bool isFirstTranscode,
bool oneSecondGop)
bool oneSecondGop,
bool isTroubleshooting)
{
_desiredState = desiredState;
_mediaFrameRate = mediaFrameRate;
@ -27,12 +29,13 @@ public class OutputFormatHls : IPipelineStep @@ -27,12 +29,13 @@ public class OutputFormatHls : IPipelineStep
_playlistPath = playlistPath;
_isFirstTranscode = isFirstTranscode;
_oneSecondGop = oneSecondGop;
_isTroubleshooting = isTroubleshooting;
}
public EnvironmentVariable[] EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public string[] GlobalOptions => Array.Empty<string>();
public string[] InputOptions(InputFile inputFile) => Array.Empty<string>();
public string[] FilterOptions => Array.Empty<string>();
public EnvironmentVariable[] EnvironmentVariables => [];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions => [];
public string[] OutputOptions
{
@ -55,11 +58,21 @@ public class OutputFormatHls : IPipelineStep @@ -55,11 +58,21 @@ public class OutputFormatHls : IPipelineStep
_segmentTemplate
];
if (_isTroubleshooting)
{
result.AddRange(
[
"-hls_playlist_type", "vod"
]);
}
string pdt = _isTroubleshooting ? string.Empty : "program_date_time+omit_endlist+";
if (_isFirstTranscode)
{
result.AddRange(
[
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
"-hls_flags", $"{pdt}append_list+independent_segments",
_playlistPath
]);
}
@ -67,7 +80,7 @@ public class OutputFormatHls : IPipelineStep @@ -67,7 +80,7 @@ public class OutputFormatHls : IPipelineStep
{
result.AddRange(
[
"-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments",
"-hls_flags", $"{pdt}append_list+discont_start+independent_segments",
"-mpegts_flags", "+initial_discontinuity",
_playlistPath
]);

3
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -334,7 +334,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -334,7 +334,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
segmentTemplate,
playlistPath,
ffmpegState.PtsOffset == 0,
ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv));
ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv,
ffmpegState.IsTroubleshooting));
}
}

28
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -15,6 +15,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker @@ -15,6 +15,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
private bool _plex;
private bool _plexCollections;
private bool _trakt;
private bool _troubleshootingPlayback;
public event EventHandler OnLibraryChanged;
public event EventHandler OnPlexChanged;
@ -23,6 +24,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker @@ -23,6 +24,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public event EventHandler OnEmbyCollectionsChanged;
public event EventHandler OnJellyfinCollectionsChanged;
public event EventHandler OnPlexCollectionsChanged;
public event EventHandler OnTroubleshootingPlaybackChanged;
public bool LockLibrary(int libraryId)
{
@ -232,4 +234,30 @@ public class EntityLocker(IMediator mediator) : IEntityLocker @@ -232,4 +234,30 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
}
public bool IsPlayoutLocked(int playoutId) => _lockedPlayouts.ContainsKey(playoutId);
public bool LockTroubleshootingPlayback()
{
if (!_troubleshootingPlayback)
{
_troubleshootingPlayback = true;
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockTroubleshootingPlayback()
{
if (_troubleshootingPlayback)
{
_troubleshootingPlayback = false;
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool IsTroubleshootingPlaybackLocked() => _troubleshootingPlayback;
}

2
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -379,6 +379,7 @@ public class TranscodingTests @@ -379,6 +379,7 @@ public class TranscodingTests
0,
None,
false,
Option<string>.None,
_ => { });
// Console.WriteLine($"ffmpeg arguments {process.Arguments}");
@ -655,6 +656,7 @@ public class TranscodingTests @@ -655,6 +656,7 @@ public class TranscodingTests
0,
None,
false,
Option<string>.None,
PipelineAction);
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");

86
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
using System.Threading.Channels;
using CliWrap;
using ErsatzTV.Application;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class TroubleshootController(
IEntityLocker entityLocker,
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
ILocalFileSystem localFileSystem,
IMediator mediator) : ControllerBase
{
[HttpHead("api/troubleshoot/playback.m3u8")]
[HttpGet("api/troubleshoot/playback.m3u8")]
public async Task<IActionResult> TroubleshootPlayback(
[FromQuery]
int mediaItem,
[FromQuery]
int ffmpegProfile,
[FromQuery]
int watermark,
CancellationToken cancellationToken)
{
entityLocker.LockTroubleshootingPlayback();
Either<BaseError, Command> result = await mediator.Send(
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark),
cancellationToken);
return await result.MatchAsync<IActionResult>(
async command =>
{
await channelWriter.WriteAsync(new StartTroubleshootingPlayback(command), CancellationToken.None);
string playlistFile = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting", "live.m3u8");
DateTimeOffset start = DateTimeOffset.Now;
while (!localFileSystem.FileExists(playlistFile) &&
DateTimeOffset.Now - start < TimeSpan.FromSeconds(15))
{
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
return Redirect("~/iptv/session/.troubleshooting/live.m3u8");
},
_ => NotFound());
}
[HttpHead("api/troubleshoot/playback/archive")]
[HttpGet("api/troubleshoot/playback/archive")]
public async Task<IActionResult> TroubleshootPlaybackArchive(
[FromQuery]
int mediaItem,
[FromQuery]
int ffmpegProfile,
[FromQuery]
int watermark,
CancellationToken cancellationToken)
{
Option<string> maybeArchivePath = await mediator.Send(
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark),
cancellationToken);
foreach (string archivePath in maybeArchivePath)
{
FileStream fs = System.IO.File.OpenRead(archivePath);
return File(
fs,
"application/zip",
$"ersatztv-troubleshooting-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip");
}
return NotFound();
}
}

165
ErsatzTV/Pages/PlaybackTroubleshooting.razor

@ -0,0 +1,165 @@ @@ -0,0 +1,165 @@
@page "/system/troubleshooting/playback"
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Watermarks
@implements IDisposable
@inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime
@inject IEntityLocker Locker
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
Class="ml-6"
StartIcon="@Icons.Material.Filled.Download"
Disabled="@(!_hasPlayed || Locker.IsTroubleshootingPlaybackLocked())"
OnClick="DownloadResults">
Download Results
</MudButton>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Media Item</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Media Item ID</MudText>
</div>
<MudTextField T="int?" Value="_mediaItemId" ValueChanged="@(async x => await OnMediaItemIdChanged(x))" />
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Title</MudText>
</div>
<MudTextField Value="@(_info?.Title)" Disabled="true" />
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playback Settings</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>FFmpeg Profile</MudText>
</div>
<MudSelect @bind-Value="_ffmpegProfileId" For="@(() => _ffmpegProfileId)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Watermark</MudText>
</div>
<MudSelect @bind-Value="_watermarkId" For="@(() => _watermarkId)" Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlayCircle"
Disabled="@(Locker.IsTroubleshootingPlaybackLocked() || _mediaItemId is null)"
OnClick="PreviewChannel">
Play
</MudButton>
</MudStack>
<div class="d-flex" style="width: 100%">
<media-controller style="aspect-ratio: 16/9; width: 100%">
<video id="video" slot="media"></video>
<media-control-bar>
<media-play-button></media-play-button>
<media-mute-button></media-mute-button>
<media-volume-range></media-volume-range>
<media-fullscreen-button></media-fullscreen-button>
</media-control-bar>
</media-controller>
<div class="d-none d-md-flex" style="width: 400px"></div>
</div>
<div class="mb-6">
<br />
<br />
</div>
</MudContainer>
</div>
</MudForm>
@code {
private readonly CancellationTokenSource _cts = new();
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private List<WatermarkViewModel> _watermarks = [];
private int? _mediaItemId;
private MediaItemInfo _info;
private int _ffmpegProfileId;
private int? _watermarkId;
private bool _hasPlayed;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
protected override void OnInitialized() => Locker.OnTroubleshootingPlaybackChanged += LockChanged;
protected override async Task OnParametersSetAsync()
{
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token);
if (_ffmpegProfiles.Count > 0)
{
_ffmpegProfileId = _ffmpegProfiles.Map(f => f.Id).Head();
}
_watermarks = await Mediator.Send(new GetAllWatermarks(), _cts.Token);
}
private void LockChanged(object sender, EventArgs e) => InvokeAsync(StateHasChanged);
private async Task PreviewChannel()
{
Locker.LockTroubleshootingPlayback();
_hasPlayed = true;
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri));
uri.Path = uri.Path.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8");
uri.Query = $"?mediaItem={_mediaItemId}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}";
await JsRuntime.InvokeVoidAsync("previewChannel", uri.ToString());
}
private async Task OnMediaItemIdChanged(int? mediaItemId)
{
_mediaItemId = mediaItemId;
_hasPlayed = false;
foreach (int id in Optional(mediaItemId))
{
Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(id));
foreach (MediaItemInfo info in maybeInfo.RightToSeq())
{
_info = info;
}
if (maybeInfo.IsLeft)
{
_mediaItemId = null;
}
}
StateHasChanged();
}
private async Task DownloadResults()
{
await JsRuntime.InvokeVoidAsync("window.open", $"api/troubleshoot/playback/archive?mediaItem={_mediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}");
}
}

101
ErsatzTV/Pages/Troubleshooting.razor

@ -7,50 +7,65 @@ @@ -7,50 +7,65 @@
@inject IMediator Mediator
@inject IJSRuntime JsRuntime
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudExpansionPanels>
<MudExpansionPanel Text="General" Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_troubleshootingView">@_troubleshootingInfo</code>
</pre>
<MudForm>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Playback</MudText>
<MudDivider Class="mb-6"/>
<div class="mb-6">
<MudLink Href="system/troubleshooting/playback">Playback Troubleshooting</MudLink>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_troubleshootingView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudExpansionPanel Text="NVIDIA Capabilities" Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_nvidiaView">@_nvidiaCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_nvidiaView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudExpansionPanel Text="QSV Capabilities" Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_qsvView">@_qsvCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_qsvView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudExpansionPanel Text="VAAPI Capabilities">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_vaapiView">@_vaapiCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_vaapiView)">
Copy
</MudButton>
</MudExpansionPanel>
</MudExpansionPanels>
</MudContainer>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">General</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_troubleshootingView">@_troubleshootingInfo</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_troubleshootingView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">NVIDIA Capabilities</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_nvidiaView">@_nvidiaCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_nvidiaView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">QSV Capabilities</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_qsvView">@_qsvCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_qsvView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">VAAPI Capabilities</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_vaapiView">@_vaapiCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_vaapiView)">
Copy
</MudButton>
</MudExpansionPanel>
</MudContainer>
</div>
</MudForm>
@code {
private readonly CancellationTokenSource _cts = new();

2
ErsatzTV/Pages/Watermarks.razor

@ -78,7 +78,7 @@ @@ -78,7 +78,7 @@
@code {
private readonly CancellationTokenSource _cts = new();
private List<WatermarkViewModel> _watermarks = new();
private List<WatermarkViewModel> _watermarks = [];
public void Dispose()
{

8
ErsatzTV/Services/FFmpegWorkerService.cs

@ -2,7 +2,9 @@ @@ -2,7 +2,9 @@
using Bugsnag;
using ErsatzTV.Application;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Core.Interfaces.FFmpeg;
using MediatR;
namespace ErsatzTV.Services;
@ -47,6 +49,12 @@ public class FFmpegWorkerService : BackgroundService @@ -47,6 +49,12 @@ public class FFmpegWorkerService : BackgroundService
_ffmpegSegmenterService.TouchChannel(parent.Name);
}
break;
case StartTroubleshootingPlayback startTroubleshootingPlayback:
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(startTroubleshootingPlayback, stoppingToken);
break;
}
}

Loading…
Cancel
Save