mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* support media info for more content types * add playback troubleshooting page * reorganize playback troubleshooting * fix watermarks and delay * update changelogpull/2157/head
27 changed files with 878 additions and 76 deletions
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Troubleshooting; |
||||
|
||||
public record ArchiveTroubleshootingResults(int MediaItemId, int FFmpegProfileId, int WatermarkId) |
||||
: IRequest<Option<string>>; |
@ -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; |
||||
} |
||||
} |
@ -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>>; |
@ -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 |
||||
}; |
||||
} |
||||
} |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
using CliWrap; |
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting; |
||||
|
||||
public record StartTroubleshootingPlayback(Command Command) : IRequest, IFFmpegWorkerRequest; |
@ -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 ???
|
||||
} |
||||
} |
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Errors; |
||||
|
||||
public class UnableToLocateMediaItem : BaseError |
||||
{ |
||||
public UnableToLocateMediaItem() : base("Unable to locate media item") |
||||
{ |
||||
} |
||||
} |
@ -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(); |
||||
} |
||||
|
||||
} |
@ -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}"); |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue