diff --git a/CHANGELOG.md b/CHANGELOG.md index 607469d5..c2881291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Fix Jellyfin show library paging - Properly locate and identify multiple Plex servers +- Properly restore `Unavailable`/`File Not Found` items when they are located on disk ### Added - Add basic music video credits subtitle generation diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs index 5b46170a..b135fa57 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs @@ -36,7 +36,6 @@ public class CreateChannelHandler : IRequestHandler { @@ -109,17 +107,6 @@ public class CreateChannelHandler : IRequestHandler string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase))) .ToValidation("Preferred subtitle language code is invalid"); - private static Validation ValidateSubtitleAndMusicCredits(CreateChannel createChannel) - { - if (createChannel.MusicVideoCreditsMode != ChannelMusicVideoCreditsMode.None && - createChannel.SubtitleMode == ChannelSubtitleMode.None) - { - return BaseError.New("Subtitles are required for music video credits"); - } - - return string.Empty; - } - private static async Task> ValidateNumber( TvContext dbContext, CreateChannel createChannel) diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index b8ee4a98..67c0e1f8 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -93,9 +93,8 @@ public class UpdateChannelHandler : IRequestHandler> Validate(TvContext dbContext, UpdateChannel request) => (await ChannelMustExist(dbContext, request), ValidateName(request), await ValidateNumber(dbContext, request), - ValidatePreferredAudioLanguage(request), - ValidateSubtitleAndMusicCredits(request)) - .Apply((channelToUpdate, _, _, _, _) => channelToUpdate); + ValidatePreferredAudioLanguage(request)) + .Apply((channelToUpdate, _, _, _) => channelToUpdate); private static Task> ChannelMustExist( TvContext dbContext, @@ -137,15 +136,4 @@ public class UpdateChannelHandler : IRequestHandler string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any( ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase))) .ToValidation("Preferred audio language code is invalid"); - - private static Validation ValidateSubtitleAndMusicCredits(UpdateChannel updateChannel) - { - if (updateChannel.MusicVideoCreditsMode != ChannelMusicVideoCreditsMode.None && - updateChannel.SubtitleMode == ChannelSubtitleMode.None) - { - return BaseError.New("Subtitles are required for music video credits"); - } - - return string.Empty; - } } diff --git a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs index c4db71dc..ea8cec7e 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs @@ -566,10 +566,8 @@ public class TranscodingTests Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask(); public Task> SelectSubtitleStream( - MediaVersion version, List subtitles, - StreamingMode streamingMode, - string channelNumber, + Channel channel, string preferredSubtitleLanguage, ChannelSubtitleMode subtitleMode) => subtitles.HeadOrNone().AsTask(); diff --git a/ErsatzTV.Core/Domain/Metadata/SubtitleKind.cs b/ErsatzTV.Core/Domain/Metadata/SubtitleKind.cs index 1a927089..fa2cbcf2 100644 --- a/ErsatzTV.Core/Domain/Metadata/SubtitleKind.cs +++ b/ErsatzTV.Core/Domain/Metadata/SubtitleKind.cs @@ -3,5 +3,7 @@ public enum SubtitleKind { Embedded = 0, - Sidecar = 1 + Sidecar = 1, + + Generated = 99 } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index ecb9a6f7..621e5d4b 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -71,10 +71,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService preferredAudioLanguage); Option maybeSubtitle = await _ffmpegStreamSelector.SelectSubtitleStream( - videoVersion, subtitles, - channel.StreamingMode, - channel.Number, + channel, preferredSubtitleLanguage, subtitleMode); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs index eb2a01dc..a79fb6ec 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs @@ -86,19 +86,24 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector } public async Task> SelectSubtitleStream( - MediaVersion version, List subtitles, - StreamingMode streamingMode, - string channelNumber, + Channel channel, string preferredSubtitleLanguage, ChannelSubtitleMode subtitleMode) { + if (channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles && + subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle) + { + _logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number); + return Optional(generatedSubtitle); + } + if (subtitleMode == ChannelSubtitleMode.None) { return None; } - if (streamingMode == StreamingMode.HttpLiveStreamingDirect && + if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect && string.IsNullOrWhiteSpace(preferredSubtitleLanguage)) { // _logger.LogDebug( @@ -110,7 +115,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant(); if (string.IsNullOrWhiteSpace(language)) { - _logger.LogDebug("Channel {Number} has no preferred subtitle language code", channelNumber); + _logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number); } else { @@ -152,7 +157,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector _logger.LogDebug( "Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}", - channelNumber, + channel.Number, subtitleMode, preferredSubtitleLanguage); diff --git a/ErsatzTV.Core/FFmpeg/MusicVideoCreditsGenerator.cs b/ErsatzTV.Core/FFmpeg/MusicVideoCreditsGenerator.cs index 55c10495..e4ca0a5b 100644 --- a/ErsatzTV.Core/FFmpeg/MusicVideoCreditsGenerator.cs +++ b/ErsatzTV.Core/FFmpeg/MusicVideoCreditsGenerator.cs @@ -64,12 +64,18 @@ public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator .WithShadow(3) .WithFormattedContent(sb.ToString()) .WithStartEnd(TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(16)) + .WithFade(true) .BuildFile(); return new Subtitle { - Codec = "ass", Default = true, Forced = true, IsExtracted = false, SubtitleKind = SubtitleKind.Sidecar, - Path = subtitles, SDH = false + Codec = "ass", + Default = true, + Forced = true, + IsExtracted = false, + SubtitleKind = SubtitleKind.Generated, + Path = subtitles, + SDH = false }; } diff --git a/ErsatzTV.Core/FFmpeg/SubtitleBuilder.cs b/ErsatzTV.Core/FFmpeg/SubtitleBuilder.cs index bce33304..259a0be8 100644 --- a/ErsatzTV.Core/FFmpeg/SubtitleBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/SubtitleBuilder.cs @@ -10,6 +10,7 @@ public class SubtitleBuilder private Option _borderStyle; private string _content; private Option _end; + private bool _fade; private Option _fontName; private Option _fontSize; private int _marginLeft; @@ -102,6 +103,12 @@ public class SubtitleBuilder return this; } + public SubtitleBuilder WithFade(bool fade) + { + _fade = fade; + return this; + } + public async Task BuildFile() { string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle); @@ -137,10 +144,12 @@ public class SubtitleBuilder end = $"{(int)endTime.TotalHours:00}:{endTime.ToString(@"mm\:ss\.ff")}"; } + string fade = _fade ? @"{\fad(1200, 1200)}" : string.Empty; + sb.AppendLine("[Events]"); sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"); sb.AppendLine( - @$"Dialogue: 0,{start},{end},Default,,{_marginLeft},{_marginRight},{_marginV},,{{\fad(1200,1200)}}{_content}"); + @$"Dialogue: 0,{start},{end},Default,,{_marginLeft},{_marginRight},{_marginV},,{fade}{_content}"); await File.WriteAllTextAsync(fileName, sb.ToString()); diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs index 6e869379..b4d4c7e5 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegStreamSelector.cs @@ -13,10 +13,8 @@ public interface IFFmpegStreamSelector string preferredAudioLanguage); Task> SelectSubtitleStream( - MediaVersion version, List subtitles, - StreamingMode streamingMode, - string channelNumber, + Channel channel, string preferredSubtitleLanguage, ChannelSubtitleMode subtitleMode); } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs index b81509a4..797e3728 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs @@ -19,6 +19,8 @@ public interface IMediaServerTelevisionRepository SetEtag(TSeason season, string etag); Task SetEtag(TEpisode episode, string etag); Task FlagNormal(TLibrary library, TEpisode episode); + Task FlagNormal(TLibrary library, TSeason season); + Task FlagNormal(TLibrary library, TShow show); Task> FlagFileNotFoundShows(TLibrary library, List showItemIds); Task> FlagFileNotFoundSeasons(TLibrary library, List seasonItemIds); Task> FlagFileNotFoundEpisodes(TLibrary library, List episodeItemIds); diff --git a/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs b/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs index bad84763..fb19b9e8 100644 --- a/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs +++ b/ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs @@ -224,9 +224,10 @@ public abstract class MediaServerMovieLibraryScanner e.Etag ?? string.Empty).IfNoneAsync(string.Empty); MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal); - if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming)) + if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound && + existingEtag == MediaServerEtag(incoming)) { - // skip scanning unavailable items that are unchanged and still don't exist locally + // skip scanning unavailable/file not found items that are unchanged and still don't exist locally if (!_localFileSystem.FileExists(localPath)) { return false; diff --git a/ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs b/ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs index e063edb3..c8f4722b 100644 --- a/ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs +++ b/ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs @@ -189,6 +189,11 @@ public abstract class MediaServerTelevisionLibraryScanner { result.Item.Id }); @@ -344,6 +349,11 @@ public abstract class MediaServerTelevisionLibraryScanner e.Etag ?? string.Empty).IfNoneAsync(string.Empty); MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal); - if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming)) + if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound && + existingEtag == MediaServerEtag(incoming)) { - // skip scanning unavailable items that are unchanged and still don't exist locally + // skip scanning unavailable/file not found items that are unchanged and still don't exist locally if (!_localFileSystem.FileExists(localPath)) { return false; diff --git a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs index 16862e59..ef6027fb 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs @@ -209,6 +209,36 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0); } + public async Task FlagNormal(EmbyLibrary library, EmbySeason season) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + season.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT EmbySeason.Id FROM EmbySeason + INNER JOIN MediaItem MI ON MI.Id = EmbySeason.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbySeason.ItemId = @ItemId)", + new { LibraryId = library.Id, season.ItemId }).Map(count => count > 0); + } + + public async Task FlagNormal(EmbyLibrary library, EmbyShow show) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + show.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT EmbyShow.Id FROM EmbyShow + INNER JOIN MediaItem MI ON MI.Id = EmbyShow.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE EmbyShow.ItemId = @ItemId)", + new { LibraryId = library.Id, show.ItemId }).Map(count => count > 0); + } + public async Task> FlagFileNotFoundShows(EmbyLibrary library, List showItemIds) { if (showItemIds.Count == 0) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs index 6ee03bc3..4d0d53c2 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs @@ -213,6 +213,36 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0); } + public async Task FlagNormal(JellyfinLibrary library, JellyfinSeason season) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + season.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT JellyfinSeason.Id FROM JellyfinSeason + INNER JOIN MediaItem MI ON MI.Id = JellyfinSeason.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinSeason.ItemId = @ItemId)", + new { LibraryId = library.Id, season.ItemId }).Map(count => count > 0); + } + + public async Task FlagNormal(JellyfinLibrary library, JellyfinShow show) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + show.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT JellyfinShow.Id FROM JellyfinShow + INNER JOIN MediaItem MI ON MI.Id = JellyfinShow.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE JellyfinShow.ItemId = @ItemId)", + new { LibraryId = library.Id, show.ItemId }).Map(count => count > 0); + } + public async Task> FlagFileNotFoundShows(JellyfinLibrary library, List showItemIds) { if (showItemIds.Count == 0) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs index 8407148d..5776d080 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs @@ -31,6 +31,36 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository new { LibraryId = library.Id, episode.Key }).Map(count => count > 0); } + public async Task FlagNormal(PlexLibrary library, PlexSeason season) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + season.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT PlexSeason.Id FROM PlexSeason + INNER JOIN MediaItem MI ON MI.Id = PlexSeason.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexSeason.Key = @Key)", + new { LibraryId = library.Id, season.Key }).Map(count => count > 0); + } + + public async Task FlagNormal(PlexLibrary library, PlexShow show) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + + show.State = MediaItemState.Normal; + + return await dbContext.Connection.ExecuteAsync( + @"UPDATE MediaItem SET State = 0 WHERE Id IN + (SELECT PlexShow.Id FROM PlexShow + INNER JOIN MediaItem MI ON MI.Id = PlexShow.Id + INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId + WHERE PlexShow.Key = @Key)", + new { LibraryId = library.Id, show.Key }).Map(count => count > 0); + } + public async Task> FlagUnavailable(PlexLibrary library, PlexEpisode episode) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();