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 @@ |
|||||||
|
namespace ErsatzTV.Application.Troubleshooting; |
||||||
|
|
||||||
|
public record ArchiveTroubleshootingResults(int MediaItemId, int FFmpegProfileId, int WatermarkId) |
||||||
|
: IRequest<Option<string>>; |
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
using CliWrap; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Troubleshooting; |
||||||
|
|
||||||
|
public record StartTroubleshootingPlayback(Command Command) : IRequest, IFFmpegWorkerRequest; |
@ -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 @@ |
|||||||
|
namespace ErsatzTV.Core.Errors; |
||||||
|
|
||||||
|
public class UnableToLocateMediaItem : BaseError |
||||||
|
{ |
||||||
|
public UnableToLocateMediaItem() : base("Unable to locate media item") |
||||||
|
{ |
||||||
|
} |
||||||
|
} |
@ -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 @@ |
|||||||
|
@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