From d5608ac75fc14ea572601339dce52f96255737a0 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:06:55 -0500 Subject: [PATCH] multiple bug fixes (#2320) * fix incorrect media counts in local libraries * completely replace imagesharp with skiasharp * fix song troubleshooting playback * fix usings --- CHANGELOG.md | 2 + .../Commands/UpdateLocalLibraryHandler.cs | 36 +++++----- .../PrepareTroubleshootingPlaybackHandler.cs | 66 +++++++++++++++---- .../ErsatzTV.Infrastructure.csproj | 7 +- ErsatzTV.Infrastructure/Images/ImageCache.cs | 55 ++++++++++------ 5 files changed, 112 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e92ef2a..a098b793f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix playback of anamorphic content from Jellyfin - This fix requires a manual deep scan of any affected Jellyfin library - Fix bug where multiple Plex servers would mix their episodes +- Fix incorrect media item counts after removing paths from local libraries +- Fix song playback in playback troubleshooting ### Changed - Allow multiple watermarks in playback troubleshooting diff --git a/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs index 63399de1c..807101377 100644 --- a/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs @@ -53,45 +53,51 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase, .Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path))) .ToList(); - var toRemoveIds = toRemove.Map(lp => lp.Id).ToList(); + var toRemoveIds = toRemove.Map(lp => lp.Id).ToHashSet(); - await dbContext.Connection.ExecuteAsync( + int changeCount = 0; + + // save item ids first; will need to remove from search index + List itemsToRemove = await dbContext.MediaItems + .AsNoTracking() + .Filter(mi => toRemoveIds.Contains(mi.LibraryPathId)) + .Map(mi => mi.Id) + .ToListAsync(); + + changeCount += await dbContext.Connection.ExecuteAsync( "DELETE FROM MediaItem WHERE LibraryPathId IN @Ids", new { Ids = toRemoveIds }); // delete all library folders (children first) IOrderedQueryable orderedFolders = dbContext.LibraryFolders + .AsNoTracking() .Filter(lf => toRemoveIds.Contains(lf.LibraryPathId)) .OrderByDescending(lp => lp.Path.Length); foreach (LibraryFolder folder in orderedFolders) { - await dbContext.Connection.ExecuteAsync( + changeCount += await dbContext.Connection.ExecuteAsync( "DELETE FROM LibraryFolder WHERE Id = @LibraryFolderId", new { LibraryFolderId = folder.Id }); } - await dbContext.LibraryPaths + changeCount += await dbContext.LibraryPaths .Filter(lp => toRemoveIds.Contains(lp.Id)) .ExecuteDeleteAsync(); existing.Paths.AddRange(toAdd); - if (await dbContext.SaveChangesAsync() > 0) - { - List itemsToRemove = await dbContext.MediaItems - .AsNoTracking() - .Filter(mi => toRemoveIds.Contains(mi.LibraryPathId)) - .Map(mi => mi.Id) - .ToListAsync(); + changeCount += await dbContext.SaveChangesAsync(); + if (changeCount > 0) + { await _searchIndex.RemoveItems(itemsToRemove); _searchIndex.Commit(); - } - if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id)) - { - await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id)); + if (_entityLocker.LockLibrary(existing.Id)) + { + await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id)); + } } return ProjectToViewModel(existing); diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 7896e57da..b7b2d38e3 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -13,6 +13,7 @@ using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Notifications; using ErsatzTV.FFmpeg; +using ErsatzTV.FFmpeg.State; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; @@ -27,6 +28,7 @@ public class PrepareTroubleshootingPlaybackHandler( IEmbyPathReplacementService embyPathReplacementService, IFFmpegProcessService ffmpegProcessService, ILocalFileSystem localFileSystem, + ISongVideoGenerator songVideoGenerator, IEntityLocker entityLocker, IMediator mediator, ILogger logger) @@ -80,6 +82,17 @@ public class PrepareTroubleshootingPlaybackHandler( return BaseError.New("Media item does not exist on disk"); } + var channel = new Channel(Guid.Empty) + { + Artwork = [], + Name = "ETV", + Number = ".troubleshooting", + FFmpegProfile = ffmpegProfile, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting, + SubtitleMode = SUBTITLE_MODE + }; + List watermarks = []; if (request.WatermarkIds.Count > 0) { @@ -90,6 +103,44 @@ public class PrepareTroubleshootingPlaybackHandler( watermarks.AddRange(channelWatermarks); } + string videoPath = mediaPath; + MediaVersion videoVersion = version; + + if (mediaItem is Song song) + { + (videoPath, videoVersion) = await songVideoGenerator.GenerateSongVideo( + song, + channel, + Option.None, + Option.None, + ffmpegPath, + ffprobePath, + CancellationToken.None); + + // override watermark as song_progress_overlay.png + if (videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true }) + { + double ratio = channel.FFmpegProfile.Resolution.Width / + (double)channel.FFmpegProfile.Resolution.Height; + bool is43 = Math.Abs(ratio - 4.0 / 3.0) < 0.01; + string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png"; + + watermarks.Clear(); + watermarks.Add(new ChannelWatermark + { + Mode = ChannelWatermarkMode.Permanent, + Size = WatermarkSize.Scaled, + WidthPercent = 100, + HorizontalMarginPercent = 0, + VerticalMarginPercent = 0, + Opacity = 100, + Location = WatermarkLocation.TopLeft, + ImageSource = ChannelWatermarkImageSource.Resource, + Image = image + }); + } + } + DateTimeOffset now = DateTimeOffset.Now; var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30)); @@ -130,19 +181,10 @@ public class PrepareTroubleshootingPlaybackHandler( ffmpegPath, ffprobePath, true, - new Channel(Guid.Empty) - { - Artwork = [], - Name = "ETV", - Number = ".troubleshooting", - FFmpegProfile = ffmpegProfile, - StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, - StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting, - SubtitleMode = SUBTITLE_MODE - }, - version, + channel, + videoVersion, new MediaItemAudioVersion(mediaItem, version), - mediaPath, + videoPath, mediaPath, _ => GetSelectedSubtitle(mediaItem, request), string.Empty, diff --git a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj index 15b6d39aa..ea7ec6d92 100644 --- a/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj +++ b/ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj @@ -10,7 +10,7 @@ - + @@ -35,13 +35,8 @@ - - - - - diff --git a/ErsatzTV.Infrastructure/Images/ImageCache.cs b/ErsatzTV.Infrastructure/Images/ImageCache.cs index 1b858247f..0d6a8a698 100644 --- a/ErsatzTV.Infrastructure/Images/ImageCache.cs +++ b/ErsatzTV.Infrastructure/Images/ImageCache.cs @@ -1,18 +1,15 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Security.Cryptography; using System.Text; -using Blurhash.ImageSharp; +using Blurhash.SkiaSharp; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; -using Image = SixLabors.ImageSharp.Image; +using SkiaSharp; namespace ErsatzTV.Infrastructure.Images; @@ -123,28 +120,42 @@ public class ImageCache : IImageCache return Path.Combine(baseFolder, fileName); } - public async Task CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y) + public Task CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y) { string targetFile = GetPathForImage(fileName, artworkKind, Option.None); // ReSharper disable once ConvertToUsingDeclaration - using (var image = await Image.LoadAsync(targetFile)) + using (var image = SKBitmap.Decode(targetFile)) { // resize before calculating blur hash; it doesn't need giant images - if (image.Height > 200) + if (image.Height > 200 || image.Width > 200) { - image.Mutate(i => i.Resize(0, 200)); - } - else if (image.Width > 200) - { - image.Mutate(i => i.Resize(200, 0)); + int width, height; + if (image.Width > image.Height) + { + width = 200; + height = (int)Math.Round(image.Height * (200.0 / image.Width)); + } + else + { + height = 200; + width = (int)Math.Round(image.Width * (200.0 / image.Height)); + } + + var info = new SKImageInfo(width, height); + + // ReSharper disable once ConvertToUsingDeclaration + using (SKBitmap resized = image.Resize(info, SKSamplingOptions.Default)) + { + return Task.FromResult(Blurhasher.Encode(resized, x, y)); + } } - return Blurhasher.Encode(image, x, y); + return Task.FromResult(Blurhasher.Encode(image, x, y)); } } - public async Task WriteBlurHash(string blurHash, IDisplaySize targetSize) + public Task WriteBlurHash(string blurHash, IDisplaySize targetSize) { byte[] bytes = Encoding.UTF8.GetBytes(blurHash); string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", ""); @@ -155,17 +166,19 @@ public class ImageCache : IImageCache _localFileSystem.EnsureFolderExists(folder); // ReSharper disable once ConvertToUsingDeclaration - // ReSharper disable once UseAwaitUsing using (FileStream fs = File.OpenWrite(targetFile)) { - using (Image image = Blurhasher.Decode(blurHash, targetSize.Width, targetSize.Height)) + using (SKBitmap image = Blurhasher.Decode(blurHash, targetSize.Width, targetSize.Height)) { - await image.SaveAsPngAsync(fs); + using (SKData data = image.Encode(SKEncodedImageFormat.Png, 100)) + { + data.SaveTo(fs); + } } } } - return targetFile; + return Task.FromResult(targetFile); } [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] @@ -180,4 +193,4 @@ public class ImageCache : IImageCache return await md5.ComputeHashAsync(fs); } } -} +} \ No newline at end of file