From 372c1681d8272c6fa8f120a132bc40351067fb10 Mon Sep 17 00:00:00 2001 From: Daniel Țuțuianu Date: Sat, 23 May 2026 23:29:25 +0300 Subject: Refresh Live TV channel icons on every guide update. Guide refresh skipped channel logos once a primary image existed, so stale EPG/tuner icons never got replaced. --- src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 4 +- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 14 ++---- src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs | 33 ++++++++++++++ .../LiveTvChannelImageHelperTests.cs | 51 ++++++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index ed02fe6a1d..e421601092 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.LiveTv; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -1109,9 +1110,8 @@ namespace Jellyfin.LiveTv.Channels item.Path = mediaSource?.Path; } - if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl)) { - item.SetImagePath(ImageType.Primary, info.ImageUrl); _logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name); forceUpdate = true; } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..b8545cbb64 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using Jellyfin.LiveTv; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; @@ -448,18 +449,9 @@ public class GuideManager : IGuideManager item.Name = channelInfo.Name; - if (!item.HasImage(ImageType.Primary)) + if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl)) { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } + forceUpdate = true; } if (isNew) diff --git a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs new file mode 100644 index 0000000000..a590193b5f --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs @@ -0,0 +1,33 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.LiveTv; + +/// +/// Helpers for keeping Live TV channel icons in sync with guide data. +/// +internal static class LiveTvChannelImageHelper +{ + /// + /// Applies the channel icon from guide or tuner metadata. + /// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged. + /// + /// The channel item. + /// The local image path from the tuner, if any. + /// The remote image URL from the guide provider, if any. + /// true when the item image metadata was updated. + internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl) + { + var newImageSource = !string.IsNullOrWhiteSpace(imagePath) + ? imagePath + : imageUrl; + + if (string.IsNullOrWhiteSpace(newImageSource)) + { + return false; + } + + item.SetImagePath(ImageType.Primary, newImageSource); + return true; + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs new file mode 100644 index 0000000000..f44cb88834 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs @@ -0,0 +1,51 @@ +using Jellyfin.LiveTv; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.LiveTv.Tests; + +public class LiveTvChannelImageHelperTests +{ + [Fact] + public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null); + + Assert.False(updated); + Assert.False(channel.HasImage(ImageType.Primary)); + } + + [Fact] + public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( + channel, + null, + "https://example.com/icon.png"); + + Assert.True(updated); + Assert.True(channel.HasImage(ImageType.Primary)); + Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); + } + + [Fact] + public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates() + { + var channel = new LiveTvChannel { Name = "Test Channel" }; + LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png"); + + var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded( + channel, + null, + "https://example.com/icon.png"); + + Assert.True(updated); + Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary)); + } +} -- cgit v1.2.3 From 5104497331c0519c551e1af6b3999f0da0d65058 Mon Sep 17 00:00:00 2001 From: David Federman Date: Tue, 2 Jun 2026 23:12:50 -0700 Subject: Reject unsafe plugin package names in installer --- .../Updates/InstallationManager.cs | 43 ++++++++++++++++++++++ .../Updates/InstallationManagerTests.cs | 24 ++++++++++++ 2 files changed, 67 insertions(+) diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index ef53e3b326..c8a2d98bf4 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -521,9 +521,27 @@ namespace Emby.Server.Implementations.Updates return; } + if (!IsValidPackageDirectoryName(package.Name)) + { + _logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name); + throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name."); + } + // Always override the passed-in target (which is a file) and figure it out again string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); + var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath)); + var resolvedTarget = Path.GetFullPath(targetDir); + if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.", + package.Name, + resolvedTarget, + pluginsRoot); + throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory."); + } + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); @@ -572,6 +590,31 @@ namespace Emby.Server.Implementations.Updates _pluginManager.ImportPluginFrom(targetDir); } + private static bool IsValidPackageDirectoryName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal)) + { + return false; + } + + if (name.Contains('/', StringComparison.Ordinal) || name.Contains('\\', StringComparison.Ordinal)) + { + return false; + } + + if (name.AsSpan().IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + return false; + } + + return true; + } + private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 92e10c9f92..4a10b2f607 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); Assert.Null(ex); } + + [Theory] + [InlineData("../evil")] + [InlineData("..\\evil")] + [InlineData("../../escape_attempt")] + [InlineData("..")] + [InlineData(".")] + [InlineData("")] + [InlineData(" ")] + [InlineData("foo/bar")] + [InlineData("foo\\bar")] + [InlineData("/absolute")] + [InlineData("foo\0bar")] + public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name) + { + var packageInfo = new InstallationInfo() + { + Name = name, + SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip", + Checksum = "11b5b2f1a9ebc4f66d6ef19018543361" + }; + + await Assert.ThrowsAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); + } } } -- cgit v1.2.3 From 26a149a970ad1f88fbd6e1676a5098e4a63531fe Mon Sep 17 00:00:00 2001 From: David Federman Date: Wed, 3 Jun 2026 08:04:39 -0700 Subject: Address PR comment --- Emby.Server.Implementations/Updates/InstallationManager.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index c8a2d98bf4..110c388fbe 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -32,6 +32,8 @@ namespace Emby.Server.Implementations.Updates /// public class InstallationManager : IInstallationManager { + private static readonly char[] InvlidPackageNameChars = [.. Path.GetInvalidFileNameChars(), '/', '\\']; + /// /// The logger. /// @@ -602,12 +604,7 @@ namespace Emby.Server.Implementations.Updates return false; } - if (name.Contains('/', StringComparison.Ordinal) || name.Contains('\\', StringComparison.Ordinal)) - { - return false; - } - - if (name.AsSpan().IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + if (name.AsSpan().IndexOfAny(InvlidPackageNameChars) >= 0) { return false; } -- cgit v1.2.3 From 0ed27bad65aa48c4c39c74493a90c3c81795d5ab Mon Sep 17 00:00:00 2001 From: David Federman Date: Sat, 6 Jun 2026 21:55:30 -0700 Subject: Address PR comment --- Emby.Server.Implementations/Updates/InstallationManager.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 110c388fbe..6a60f7f5f6 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -32,7 +33,7 @@ namespace Emby.Server.Implementations.Updates /// public class InstallationManager : IInstallationManager { - private static readonly char[] InvlidPackageNameChars = [.. Path.GetInvalidFileNameChars(), '/', '\\']; + private static readonly SearchValues InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']); /// /// The logger. @@ -604,7 +605,7 @@ namespace Emby.Server.Implementations.Updates return false; } - if (name.AsSpan().IndexOfAny(InvlidPackageNameChars) >= 0) + if (name.IndexOfAny(InvalidPackageNameChars) >= 0) { return false; } -- cgit v1.2.3 From b9271eb19995056ab9e1c56ba810e963702c9254 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Mon, 15 Jun 2026 19:07:34 -0400 Subject: Skip parsing root-level folders in SeriesResolver --- Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index 769d721665..94e48c59ec 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + if (args.Parent is not null && args.Parent.IsRoot) + { + return null; + } + var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path); var collectionType = args.GetCollectionType(); -- cgit v1.2.3 From c6c72f30ecb09f6c3ce4296e23779367be23086f Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Thu, 18 Jun 2026 18:14:10 -0400 Subject: Fix embedded lyrics not updating on replace-all refresh --- MediaBrowser.Providers/MediaInfo/AudioFileProber.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 0ecbb6f068..b70cba5b3b 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -549,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null); var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics; if (!string.IsNullOrWhiteSpace(lyrics) - && tryExtractEmbeddedLyrics) + && (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata)) { await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false); } -- cgit v1.2.3 From e75161c557596c79c18b84a28790b9a2317bda1e Mon Sep 17 00:00:00 2001 From: danne Date: Sun, 14 Jun 2026 09:18:50 +0200 Subject: Deprecate the redundant /Trailers endpoint GET /Trailers is a thin alias for GET /Items with includeItemTypes=Trailer; it just forwards to the injected ItemsController. Per the PR review the agreed direction is to deprecate it rather than keep maintaining the delegation. Mark the action [Obsolete] so it is flagged as deprecated in the OpenAPI spec; clients should use the GetItems operation with includeItemTypes=Trailer instead. Re #17065 --- Jellyfin.Api/Controllers/TrailersController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 121db66858..6bddd85337 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -122,6 +122,7 @@ public class TrailersController : BaseJellyfinApiController /// A with the trailers. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetItems with includeItemTypes=Trailer instead.")] public async Task>> GetTrailers( [FromQuery] Guid? userId, [FromQuery] string? maxOfficialRating, -- cgit v1.2.3 From 8d15529df7b66728ba87e9e9312f498cdb0f1dd3 Mon Sep 17 00:00:00 2001 From: AfmanS Date: Sat, 20 Jun 2026 07:07:05 -0400 Subject: Translated using Weblate (Portuguese (Portugal)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pt_PT/ --- Emby.Server.Implementations/Localization/Core/pt-PT.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index dd482d1e9b..ce7f6d120e 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.", "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.", "CleanupUserDataTask": "Limpeza de dados de utilizador", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}" } -- cgit v1.2.3 From 11f642594d4c63c09fbb62c970c6a94272c7b271 Mon Sep 17 00:00:00 2001 From: Žiga Ules Date: Sat, 20 Jun 2026 05:34:09 -0400 Subject: Translated using Weblate (Slovenian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sl/ --- Emby.Server.Implementations/Localization/Core/sl-SI.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 8c8ed3254a..a1b5b714af 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -106,5 +106,7 @@ "TaskAudioNormalization": "Normalizacija zvoka", "TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.", "CleanupUserDataTask": "Čiščenje uporabniških podatkov", - "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo." + "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.", + "LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}", + "Original": "Original" } -- cgit v1.2.3 From ce58e4400e5714d4afeaa517a021601395c51944 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Sat, 20 Jun 2026 22:30:52 -0400 Subject: Fix Identify returning wrong results --- MediaBrowser.Providers/Manager/MetadataService.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index c2e523cfaf..118ccf8679 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -831,8 +831,16 @@ namespace MediaBrowser.Providers.Manager var isLocalLocked = temp.Item.IsLocked; if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly)) { - var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType>(), cancellationToken) - .ConfigureAwait(false); + var remoteProviders = providers.OfType>(); + + // When identifying, run the provider the user picked first so the correct IDs are used. + if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName)) + { + remoteProviders = remoteProviders + .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1); + } + + var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false); refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; -- cgit v1.2.3 From e4383493a96da86c99516bfdf69ecd609c2dfec2 Mon Sep 17 00:00:00 2001 From: danne Date: Sat, 13 Jun 2026 22:44:44 +0200 Subject: Fix audio sample rate forced to 48 kHz for non-Opus codecs GetProgressiveAudioFullCommandLine applied the libopus-only sample rate quantization to every codec except Opus, inverting the intended guard. A requested rate such as 44100 Hz was therefore snapped to 48000 Hz for AAC/MP3/FLAC, while Opus (which actually requires the quantization) was skipped entirely. Apply the quantization only when the output codec is Opus, and pass the requested sample rate through unchanged for all other codecs. Fixes #17026 Co-Authored-By: Claude Opus 4.8 --- .../MediaEncoding/EncodingHelper.cs | 15 ++++---- .../MediaEncoding/EncodingHelperTests.cs | 45 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 650eaa404e..847f4cf187 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -7870,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); } - if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) + var sampleRate = state.OutputAudioSampleRate; + if (sampleRate.HasValue) { - // opus only supports specific sampling rates - var sampleRate = state.OutputAudioSampleRate; - if (sampleRate.HasValue) + var sampleRateValue = sampleRate.Value; + if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) { - var sampleRateValue = sampleRate.Value switch + // opus only supports specific sampling rates + sampleRateValue = sampleRate.Value switch { <= 8000 => 8000, <= 12000 => 12000, @@ -7884,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding <= 24000 => 24000, _ => 48000 }; - - audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } + + audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture)); } // Copy the movflags from GetProgressiveVideoFullCommandLine diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs index d7ae6a8a18..71b6551d0f 100644 --- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs +++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs @@ -11,6 +11,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; using Moq; using Xunit; @@ -203,6 +204,50 @@ public class EncodingHelperTests } } + [Theory] + [InlineData("aac", 44100, 44100)] // non-opus: requested rate must be preserved (issue #17026) + [InlineData("aac", 48000, 48000)] + [InlineData("mp3", 22050, 22050)] + [InlineData("flac", 96000, 96000)] + [InlineData("opus", 44100, 48000)] // opus: must snap to a libopus-supported rate + [InlineData("opus", 22050, 24000)] + [InlineData("opus", 8000, 8000)] + public void GetProgressiveAudioFullCommandLine_SampleRate_OnlyClampedForOpus( + string audioCodec, + int requestedSampleRate, + int expectedSampleRate) + { + var state = BuildAudioState(audioCodec, requestedSampleRate); + var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out"); + + Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal); + } + + private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate) + { + var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 }; + + return new EncodingJobInfo(TranscodingJobType.Progressive) + { + MediaSource = new MediaSourceInfo + { + Container = "flac", + MediaStreams = new List { audio }, + Path = "/media/track.flac", + Protocol = MediaProtocol.File, + }, + AudioStream = audio, + OutputAudioCodec = audioCodec, + BaseRequest = new VideoRequestDto + { + AudioCodec = audioCodec, + AudioSampleRate = requestedSampleRate, + }, + IsVideoRequest = false, + IsInputVideo = false, + }; + } + private static EncodingJobInfo BuildState( MediaStream? subtitle, SubtitleDeliveryMethod? deliveryMethod, -- cgit v1.2.3 From 069eb40ebfdca4030d0c87d56f4398f6f5d1b55e Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Sun, 21 Jun 2026 22:49:46 -0400 Subject: Fix too many SQL variables in DeleteItem for large batch deletes The FixIncorrectOwnerIdRelationships migration deletes all duplicate items in a single DeleteItemsUnsafeFast -> DeleteItem(ids) call. Inside DeleteItem, the owned-extras lookup used a raw HashSet.Contains, which EF inlines as one SQL variable per id and overflows SQLite's variable limit on large libraries. Use WhereOneOrMany so the id set is bound as a single json_each parameter, like the rest of the method, making bulk deletes work for unlimited library sizes. --- Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index 7c0cfe7c15..b10f7c527e 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService descendantIds.Add(id); } + // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a + // single parameter (json_each) rather than one SQL variable per id, which would otherwise + // overflow SQLite's variable limit when deleting many items at once (e.g. migrations). + var ownerIds = descendantIds.ToArray(); var extraIds = context.BaseItems - .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value)) + .Where(e => e.OwnerId.HasValue) + .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value) .Select(e => e.Id) .ToArray(); -- cgit v1.2.3 From b60c535c84a382d06eab5b0c38ee279103b06cf2 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Sun, 21 Jun 2026 23:06:45 -0400 Subject: Add progress logging and batch deletion for logs After resolving duplicates the migration deleted all items in one silent pass (per-id GetItemById plus a single DeleteItemsUnsafeFast), which looks hung for minutes on large libraries. Delete in batches of 500 and log progress per batch, which also avoids one oversized delete transaction. --- ...60115120000_FixIncorrectOwnerIdRelationships.cs | 43 ++++++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs index 0baf261a2e..e34182fd5d 100644 --- a/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs +++ b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs @@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine if (allIdsToDelete.Count > 0) { - // Batch-resolve items for metadata path cleanup, then delete all at once - var itemsToDelete = allIdsToDelete - .Select(id => _libraryManager.GetItemById(id)) - .Where(item => item is not null) - .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - - // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager - var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); - var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); - if (unresolvedIds.Count > 0) + _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count); + + // Delete in batches so progress is visible (item resolution and deletion can take a + // long time on large libraries) and so we never issue one massive delete transaction. + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize) { - _persistenceService.DeleteItem(unresolvedIds); + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset)); + + // Resolve items for metadata path cleanup, then delete this batch + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count); } } -- cgit v1.2.3 From 0046adda29b4d99cbdf6b215d14539c08e96ab3e Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Sun, 21 Jun 2026 23:09:55 -0400 Subject: Restyle the startup UI and add a generic startup activity line Restyle the startup/migration holding page to match the Jellyfin dark theme, with the inline wordmark logo, a gradient spinner and a recolored startup log tree, and move the Morestachio template rendering into a reusable StartupUiRenderer. Add a curated, non-identifying "current activity" line to the always-visible header (for example "Initializing server" or "Running migration X of Y"), reported from the startup flow and the migration service so it never leaks server details to unauthenticated clients. Move the log download into a "Download logs" link in the log panel header, and show only the header, with no log hints, to non-local clients. --- .../Migrations/JellyfinMigrationService.cs | 3 + Jellyfin.Server/Program.cs | 6 + Jellyfin.Server/ServerSetupApp/SetupServer.cs | 87 ++------ Jellyfin.Server/ServerSetupApp/StartupActivity.cs | 44 ++++ .../ServerSetupApp/StartupUiRenderer.cs | 109 +++++++++ .../ServerSetupApp/index.mstemplate.html | 244 +++++++++++++++------ 6 files changed, 366 insertions(+), 127 deletions(-) create mode 100644 Jellyfin.Server/ServerSetupApp/StartupActivity.cs create mode 100644 Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index 9bf927bb95..a10be76e05 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -215,8 +215,11 @@ internal class JellyfinMigrationService logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + var migrationIndex = 0; foreach (var item in migrations) { + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length)); var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); try { diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index af0d424aad..2b20ee4314 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -133,10 +133,12 @@ namespace Jellyfin.Server } } + SetupServer.ReportActivity(StartupActivity.CheckingStorage); StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger()).BeginGroup($"Storage Check")); StartupHelpers.PerformStaticInitialization(); + SetupServer.ReportActivity(StartupActivity.Initializing); await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false); do @@ -195,6 +197,7 @@ namespace Jellyfin.Server if (!string.IsNullOrWhiteSpace(_restoreFromBackup)) { + SetupServer.ReportActivity(StartupActivity.RestoringBackup); await appHost.ServiceProvider.GetService()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false); _restoreFromBackup = null; _restartOnShutdown = true; @@ -202,9 +205,12 @@ namespace Jellyfin.Server } var jellyfinMigrationService = ActivatorUtilities.CreateInstance(appHost.ServiceProvider); + SetupServer.ReportActivity(StartupActivity.PreparingMigrations); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); + SetupServer.ReportActivity(StartupActivity.ApplyingMigrations); await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); + SetupServer.ReportActivity(StartupActivity.InitializingServices); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); _appHost = appHost; diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 37bb1abe71..893272590e 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; -using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; -using Morestachio; -using Morestachio.Framework.IO.SingleStream; -using Morestachio.Rendering; using Serilog; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; - private IRenderer? _startupUiRenderer; + private static volatile string _currentActivity = StartupActivity.Starting; + private StartupUiRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; private bool _isUnhealthy; @@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable internal static ConcurrentQueue? LogQueue { get; set; } = new(); + /// + /// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the + /// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details. + /// + internal static string CurrentActivity => _currentActivity; + /// /// Gets a value indicating whether Startup server is currently running. /// @@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable /// A Task. public async Task RunAsync() { - var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); - _startupUiRenderer = (await ParserOptionsBuilder.New() - .WithTemplate(fileTemplate) - .WithFormatter( - (Version version, int arg) => - { - // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. - return version.ToString(arg); - }, - "ToString") - .WithFormatter( - (StartupLogTopic logEntry, IEnumerable children) => - { - if (children.Any()) - { - var maxLevel = logEntry.LogLevel; - var stack = new Stack(children); - - while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. - { - maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; - foreach (var child in logEntry.Children) - { - stack.Push(child); - } - } - - return maxLevel; - } - - return logEntry.LogLevel; - }, - "FormatLogLevel") - .WithFormatter( - (LogLevel logLevel) => - { - switch (logLevel) - { - case LogLevel.Trace: - case LogLevel.Debug: - case LogLevel.None: - return "success"; - case LogLevel.Information: - return "info"; - case LogLevel.Warning: - return "warn"; - case LogLevel.Error: - return "danger"; - case LogLevel.Critical: - return "danger-strong"; - } - - return string.Empty; - }, - "ToString") - .BuildAndParseAsync() - .ConfigureAwait(false)) - .CreateCompiledRenderer(); + ReportActivity(StartupActivity.Starting); + _startupUiRenderer = await StartupUiRenderer.CreateAsync( + Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); ThrowIfDisposed(); var retryAfterValue = TimeSpan.FromSeconds(5); @@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable new Dictionary() { { "isInReportingMode", _isUnhealthy }, + { "currentActivity", CurrentActivity }, { "retryValue", retryAfterValue }, { "version", version }, { "logs", startupLogEntries }, { "networkManagerReady", networkManager is not null }, { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } }, - new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) + context.Response.BodyWriter.AsStream()) .ConfigureAwait(false); }); }); @@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); } + /// + /// Reports the current startup activity shown to all clients in the startup UI header. + /// Only pass generic, non-identifying text from . + /// + /// A generic description such as . + internal static void ReportActivity(string activity) + { + _currentActivity = activity; + } + internal void SoftStop() { _isUnhealthy = true; diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs new file mode 100644 index 0000000000..5baaf1d40a --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -0,0 +1,44 @@ +using System.Globalization; + +namespace Jellyfin.Server.ServerSetupApp; + +/// +/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup. +/// These are shown in the always-visible header of the startup UI to unauthenticated clients, so every +/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.). +/// +public static class StartupActivity +{ + /// The default state before any work has been reported. + public const string Starting = "Starting up"; + + /// Validating that the configured storage locations are usable. + public const string CheckingStorage = "Checking storage"; + + /// Bringing up the migration subsystem and running early startup checks. + public const string Initializing = "Initializing server"; + + /// Preparing the system for migrations (e.g. taking safety backups). + public const string PreparingMigrations = "Preparing migrations"; + + /// Applying database/system migrations without a known count. + public const string ApplyingMigrations = "Applying migrations"; + + /// Restoring from a backup. + public const string RestoringBackup = "Restoring backup"; + + /// Bringing up core services and plugins. + public const string InitializingServices = "Initializing services"; + + /// Running the final startup tasks. + public const string FinishingStartup = "Finishing startup"; + + /// + /// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed. + /// + /// The 1-based index of the migration currently running. + /// The total number of migrations in this batch. + /// A generic progress description. + public static string Migration(int current, int total) + => string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total); +} diff --git a/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs new file mode 100644 index 0000000000..db07b9d8c1 --- /dev/null +++ b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Morestachio; +using Morestachio.Framework.IO.SingleStream; +using Morestachio.Rendering; + +namespace Jellyfin.Server.ServerSetupApp; + +/// +/// Compiles and renders the startup UI Morestachio template. +/// Shared by the live and the standalone startup UI preview tool so both +/// exercise the exact same template and formatters. +/// +public sealed class StartupUiRenderer +{ + private readonly IRenderer _renderer; + + private StartupUiRenderer(IRenderer renderer) + { + _renderer = renderer; + } + + /// + /// Compiles the startup UI template located at . + /// + /// The full path to the index.mstemplate.html template. + /// A ready to use . + public static async Task CreateAsync(string templatePath) + { + var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false); + var renderer = (await ParserOptionsBuilder.New() + .WithTemplate(fileTemplate) + .WithFormatter( + (Version version, int arg) => + { + // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually. + return version.ToString(arg); + }, + "ToString") + .WithFormatter( + (StartupLogTopic logEntry, IEnumerable children) => + { + if (children.Any()) + { + var maxLevel = logEntry.LogLevel; + var stack = new Stack(children); + + while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level. + { + maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; + foreach (var child in logEntry.Children) + { + stack.Push(child); + } + } + + return maxLevel; + } + + return logEntry.LogLevel; + }, + "FormatLogLevel") + .WithFormatter( + (LogLevel logLevel) => + { + switch (logLevel) + { + case LogLevel.Trace: + case LogLevel.Debug: + case LogLevel.None: + return "success"; + case LogLevel.Information: + return "info"; + case LogLevel.Warning: + return "warn"; + case LogLevel.Error: + return "danger"; + case LogLevel.Critical: + return "danger-strong"; + } + + return string.Empty; + }, + "ToString") + .BuildAndParseAsync() + .ConfigureAwait(false)) + .CreateCompiledRenderer(); + + return new StartupUiRenderer(renderer); + } + + /// + /// Renders the template with the provided model into the target stream. + /// + /// The values made available to the template. + /// The stream the rendered HTML is written to. + /// A Task. + public Task RenderAsync(IDictionary model, Stream output) + { + return _renderer.RenderAsync( + model, + new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions)); + } +} diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 5706ce1fac..38cb5cea9e 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -1,8 +1,10 @@ - + + + {{#IF isInReportingMode}} ❌ @@ -10,8 +12,36 @@ Jellyfin Startup -
-
-
+ - {{^IF isInReportingMode}} -
- -

Jellyfin is still starting. Please wait… {{currentActivity}}

+ {{^IF isInReportingMode}} +
+ - {{#ELSE}} -
- -

Jellyfin has encountered an error and was not able to start.

-
- {{/ELSE}} - {{/IF}} -
+

Jellyfin is still starting. Please wait… {{currentActivity}}

+
+ {{#ELSE}} +
+

Jellyfin has encountered an error and was not able to start.

+
+ {{/ELSE}} + {{/IF}} {{#DECLARE LogEntry |--}} {{#LET children = Children}} @@ -350,7 +431,7 @@
    @@ -422,7 +503,7 @@ var doc = new DOMParser().parseFromString(html, 'text/html'); // Startup failed and the page switched to the error view -> reload to render it. - if (doc.querySelector('.status-card.is-error')) { + if (doc.querySelector('.status.is-error')) { window.location.reload(); return; } -- cgit v1.2.3 From 31070e8208e973728b4bfe470cbbd6ca1d14c048 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Thu, 25 Jun 2026 00:42:31 -0400 Subject: Add a cancelable redirect handoff and drop the transitional migration status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server finishes starting, show "Jellyfin started successfully" with a 5-second "Redirecting in N…" countdown and a Cancel button instead of reloading immediately. Cancel stops the countdown and the background refresh so the startup output can be reviewed, and offers a "Continue to Jellyfin" button to reload manually. The buttons use the web client's emby-button styling. Also drop the transitional "Applying migrations" activity: it only showed briefly while the pending migration set was read, or for the whole step when nothing was pending, so startup now goes from "Preparing migrations" straight into "Running migration X of Y". --- Jellyfin.Server/Program.cs | 3 +- Jellyfin.Server/ServerSetupApp/SetupServer.cs | 2 +- Jellyfin.Server/ServerSetupApp/StartupActivity.cs | 3 - .../ServerSetupApp/index.mstemplate.html | 127 ++++++++++++++++++++- 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 2b20ee4314..12f92efb35 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -207,7 +207,8 @@ namespace Jellyfin.Server var jellyfinMigrationService = ActivatorUtilities.CreateInstance(appHost.ServiceProvider); SetupServer.ReportActivity(StartupActivity.PreparingMigrations); await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false); - SetupServer.ReportActivity(StartupActivity.ApplyingMigrations); + // "Preparing migrations" carries through the DB read; per-migration progress is reported + // as "Running migration X of Y" from inside the step once the pending set is known. await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false); SetupServer.ReportActivity(StartupActivity.InitializingServices); diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 893272590e..598de5aa5f 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -262,7 +262,7 @@ public sealed class SetupServer : IDisposable /// Reports the current startup activity shown to all clients in the startup UI header. /// Only pass generic, non-identifying text from . ///
- /// A generic description such as . + /// A generic description such as . internal static void ReportActivity(string activity) { _currentActivity = activity; diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs index 5baaf1d40a..888cc617d4 100644 --- a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs +++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs @@ -21,9 +21,6 @@ public static class StartupActivity /// Preparing the system for migrations (e.g. taking safety backups). public const string PreparingMigrations = "Preparing migrations"; - /// Applying database/system migrations without a known count. - public const string ApplyingMigrations = "Applying migrations"; - /// Restoring from a backup. public const string RestoringBackup = "Restoring backup"; diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index cc37a8b4dd..9c12762c31 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -126,6 +126,63 @@ color: var(--jf-error); } + /* Buttons — matching the web client's emby-button styles. */ + .jf-button { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + margin: 0; + padding: 0.9em 1em; + border: 0; + border-radius: 0.2em; + font-family: inherit; + font-size: inherit; + font-weight: 600; + line-height: 1.35; + cursor: pointer; + outline: none; + text-decoration: none; + transition: 0.2s; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + .jf-button-primary { + background: var(--jf-primary); + color: rgba(0, 0, 0, 0.87); + } + + .jf-button-primary:hover, + .jf-button-primary:focus { + background: var(--jf-primary-dark); + } + + .jf-button-secondary { + background: #424242; + color: var(--jf-text-secondary); + } + + .jf-button-secondary:hover, + .jf-button-secondary:focus { + background: #616161; + } + + /* Redirect countdown shown once the server is ready. */ + .redirect-bar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 1em; + margin-bottom: 1.5em; + text-align: center; + } + + .redirect-countdown { + color: var(--jf-text-secondary); + } + /* Material (MDL) spinner — the same one the web client uses while loading. */ .mdl-spinner { position: relative; @@ -491,8 +548,8 @@ function poll() { fetch(window.location.href, { cache: 'no-store' }).then(function (resp) { if (resp.ok) { - // The real server is now answering (HTTP 200) -> load the actual app. - window.location.reload(); + // The real server is now answering (HTTP 200) -> offer to continue to the app. + onServerReady(); return null; } return resp.text(); @@ -530,7 +587,71 @@ }); } - setInterval(poll, intervalMs); + // The server finished starting. Stop polling and present a cancelable countdown so the + // user can either ride the redirect into the app or stay to review the startup output. + function onServerReady() { + clearInterval(pollTimer); + + var status = document.querySelector('.status'); + var statusText = document.querySelector('.status-text'); + var spinner = document.querySelector('.mdl-spinner'); + if (spinner) { + spinner.style.display = 'none'; + } + if (status) { + status.classList.add('is-success'); + } + if (statusText) { + statusText.textContent = 'Jellyfin started successfully.'; + } + if (!status) { + window.location.reload(); + return; + } + + var bar = document.createElement('div'); + bar.className = 'redirect-bar'; + var countdownText = document.createElement('span'); + countdownText.className = 'redirect-countdown'; + var cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'jf-button jf-button-secondary'; + cancelButton.textContent = 'Cancel'; + bar.appendChild(countdownText); + bar.appendChild(cancelButton); + status.insertAdjacentElement('afterend', bar); + + var remaining = 5; + function renderCountdown() { + countdownText.textContent = 'Redirecting in ' + remaining + '…'; + } + renderCountdown(); + var countdown = setInterval(function () { + remaining -= 1; + if (remaining <= 0) { + clearInterval(countdown); + window.location.reload(); + return; + } + renderCountdown(); + }, 1000); + + // Cancel stops both the redirect and the refreshing, and offers a manual continue. + cancelButton.addEventListener('click', function () { + clearInterval(countdown); + bar.innerHTML = ''; + var continueButton = document.createElement('button'); + continueButton.type = 'button'; + continueButton.className = 'jf-button jf-button-primary'; + continueButton.textContent = 'Continue to Jellyfin'; + continueButton.addEventListener('click', function () { + window.location.reload(); + }); + bar.appendChild(continueButton); + }); + } + + var pollTimer = setInterval(poll, intervalMs); })(); -- cgit v1.2.3 From 1947296edd449e9a7244d18716fcb8ff0e9f0dc8 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 25 Jun 2026 19:32:36 +0200 Subject: Don't run heavy DB tasks while scan is running --- .../ScheduledTasks/Tasks/OptimizeDatabaseTask.cs | 16 +++++++++++++++- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 7 +++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 92d7a3907a..8d133dc074 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask private readonly ILogger _logger; private readonly ILocalizationManager _localization; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; + private readonly ILibraryManager _libraryManager; /// /// Initializes a new instance of the class. @@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// Instance of the interface. /// Instance of the interface. /// Instance of the JellyfinDatabaseProvider that can be used for provider specific operations. + /// Instance of the interface. public OptimizeDatabaseTask( ILogger logger, ILocalizationManager localization, - IJellyfinDatabaseProvider jellyfinDatabaseProvider) + IJellyfinDatabaseProvider jellyfinDatabaseProvider, + ILibraryManager libraryManager) { _logger = logger; _localization = localization; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; + _libraryManager = libraryManager; } /// @@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask /// public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { + // Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in + // progress causes both operations to contend for the database and can stall the scan, so defer optimization + // until no scan is running. The task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + _logger.LogInformation("Skipping database optimization because a library scan is currently running."); + return; + } + _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); try diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 6e4e5c7808..2a38b8c446 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -71,6 +71,13 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { + // People validation performs heavy database writes that contend with an active library scan. + // Defer it until the scan has finished; the task will run again on its next trigger. + if (_libraryManager.IsScanRunning) + { + return; + } + IProgress subProgress = new Progress((val) => progress.Report(val / 2)); await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false); -- cgit v1.2.3 From b9db4566a74ec94bcd24e7333b9d0cc6156e2e25 Mon Sep 17 00:00:00 2001 From: cloudharps Date: Thu, 25 Jun 2026 02:57:41 -0400 Subject: Translated using Weblate (Korean) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ko/ --- Emby.Server.Implementations/Localization/Core/ko.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 5d64405d19..a210125d34 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyrics": "누락된 가사 다운로드", "TaskDownloadMissingLyricsDescription": "가사 다운로드", "CleanupUserDataTask": "사용자 데이터 정리 작업", - "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다." + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.", + "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다", + "Original": "원본" } -- cgit v1.2.3 From fa07a3abe89b6e0eb96a9f8d8a3eb57dea20ca2a Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 26 Jun 2026 07:34:19 +0200 Subject: Skip backups whens can is running --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 7 ++++++- .../FullSystemBackup/BackupService.cs | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index 2a38b8c446..305f98790d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -9,6 +9,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks; @@ -20,6 +21,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask private readonly ILibraryManager _libraryManager; private readonly ILocalizationManager _localization; private readonly IDbContextFactory _dbContextFactory; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -27,11 +29,13 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory dbContextFactory) + /// Instance of the interface. + public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory dbContextFactory, ILogger logger) { _libraryManager = libraryManager; _localization = localization; _dbContextFactory = dbContextFactory; + _logger = logger; } /// @@ -75,6 +79,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask // Defer it until the scan has finished; the task will run again on its next trigger. if (_libraryManager.IsScanRunning) { + _logger.LogInformation("Skipping people validation because a library scan is currently running."); return; } diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index a6dc5458ee..a534fa5fa0 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Server.Implementations.StorageHelpers; using Jellyfin.Server.Implementations.SystemBackupService; using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SystemBackupService; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -33,6 +34,7 @@ public class BackupService : IBackupService private readonly IServerApplicationPaths _applicationPaths; private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILibraryManager _libraryManager; private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General) { AllowTrailingCommas = true, @@ -50,13 +52,15 @@ public class BackupService : IBackupService /// The application paths. /// The Jellyfin database Provider in use. /// The SystemManager. + /// Instance of the interface. public BackupService( ILogger logger, IDbContextFactory dbProvider, IServerApplicationHost applicationHost, IServerApplicationPaths applicationPaths, IJellyfinDatabaseProvider jellyfinDatabaseProvider, - IHostApplicationLifetime applicationLifetime) + IHostApplicationLifetime applicationLifetime, + ILibraryManager libraryManager) { _logger = logger; _dbProvider = dbProvider; @@ -64,6 +68,7 @@ public class BackupService : IBackupService _applicationPaths = applicationPaths; _jellyfinDatabaseProvider = jellyfinDatabaseProvider; _hostApplicationLifetime = applicationLifetime; + _libraryManager = libraryManager; } /// @@ -263,6 +268,14 @@ public class BackupService : IBackupService /// public async Task CreateBackupAsync(BackupOptionsDto backupOptions) { + // Creating a backup runs a database optimization and reads the entire database under a transaction, both of + // which heavily contend with an active library scan and could capture an inconsistent database state. + if (_libraryManager.IsScanRunning) + { + _logger.LogWarning("Cannot create a backup while a library scan is running."); + throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished."); + } + var manifest = new BackupManifest() { DateCreated = DateTime.UtcNow, -- cgit v1.2.3 From f398b6d08b46544f61523c6871624201a2b54dfc Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 26 Jun 2026 08:20:55 +0200 Subject: Fix localization lookup --- .../Localization/Core/en_US.json | 64 ---------------------- .../Localization/LocalizationManager.cs | 10 +++- .../Localization/LocalizationManagerTests.cs | 14 +++++ 3 files changed, 21 insertions(+), 67 deletions(-) delete mode 100644 Emby.Server.Implementations/Localization/Core/en_US.json diff --git a/Emby.Server.Implementations/Localization/Core/en_US.json b/Emby.Server.Implementations/Localization/Core/en_US.json deleted file mode 100644 index b093f73099..0000000000 --- a/Emby.Server.Implementations/Localization/Core/en_US.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "AppDeviceValues": "App: {0}, Device: {1}", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "Default": "Default", - "External": "External", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Forced": "Forced", - "Genres": "Genres", - "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HearingImpaired": "Hearing Impaired", - "HomeVideos": "Home Videos", - "Inherit": "Inherit", - "LabelIpAddressValue": "IP address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music Videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "NotificationOptionPluginUninstalled": "Plugin uninstalled", - "NotificationOptionPluginUpdateInstalled": "Plugin update installed", - "NotificationOptionServerRestartRequired": "Server restart required", - "NotificationOptionTaskFailed": "Scheduled task failure", - "NotificationOptionUserLockedOut": "User locked out", - "NotificationOptionVideoPlayback": "Video playback started", - "NotificationOptionVideoPlaybackStopped": "Video playback stopped", - "Original": "Original", - "Photos": "Photos", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ScheduledTaskFailedWithName": "{0} failed", - "Shows": "Shows", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} to {1}", - "TvShows": "TV Shows", - "Undefined": "Undefined", - "UserCreatedWithName": "User {0} has been created", - "UserDeletedWithName": "User {0} has been deleted" -} diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 843e35afcc..6971431155 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -566,11 +566,15 @@ namespace Emby.Server.Implementations.Localization private static string GetResourceFilename(string culture) { - var parts = culture.Split('-'); + // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator. + // Normalize the casing (lower-case language, upper-case region) while preserving the separator + // so the result matches the embedded resource file name, which is case-sensitive. + var separatorIndex = culture.IndexOfAny(['-', '_']); - if (parts.Length == 2) + if (separatorIndex > 0) { - culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant(); + var separator = culture[separatorIndex]; + culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant(); } else { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 3b8fe5ca60..bdb726f06d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -344,6 +344,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.NotEqual("Default", translated); } + [Fact] + public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation() + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + + // he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive, + // so the region casing has to be preserved or the file is not found and we fall back to en-US. + var translated = localizationManager.GetLocalizedString("Books", "he-IL"); + Assert.Equal("ספרים", translated); + } + [Fact] public void GetServerLocalizedString_UsesServerCulture() { -- cgit v1.2.3 From c2cb18a9d1c936d069679052aa83ef7362849d91 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 26 Jun 2026 11:42:28 +0200 Subject: Fix local plugin registration --- Emby.Server.Implementations/ApplicationHost.cs | 11 +++++++++++ .../Books/ComicServiceRegistrator.cs | 23 ---------------------- 2 files changed, 11 insertions(+), 23 deletions(-) delete mode 100644 MediaBrowser.Providers/Books/ComicServiceRegistrator.cs diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 14380c33bf..69e23bcb63 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -93,6 +93,9 @@ using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; +using MediaBrowser.Providers.Books; +using MediaBrowser.Providers.Books.ComicBookInfo; +using MediaBrowser.Providers.Books.ComicInfo; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.ListenBrainz; @@ -496,6 +499,14 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + // register the generic local metadata provider for comic files + serviceCollection.AddSingleton(); + + // register the actual implementations of the local metadata provider for comic files + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(NetManager); serviceCollection.AddSingleton(); diff --git a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs b/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs deleted file mode 100644 index 0d096241d6..0000000000 --- a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using MediaBrowser.Controller; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Providers.Books.ComicBookInfo; -using MediaBrowser.Providers.Books.ComicInfo; -using Microsoft.Extensions.DependencyInjection; - -namespace MediaBrowser.Providers.Books; - -/// -public class ComicServiceRegistrator : IPluginServiceRegistrator -{ - /// - public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) - { - // register the generic local metadata provider for comic files - serviceCollection.AddSingleton(); - - // register the actual implementations of the local metadata provider for comic files - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - } -} -- cgit v1.2.3 From f2ed842b4be26966121945e7c46f00ed15023ed5 Mon Sep 17 00:00:00 2001 From: Manuel Cid Date: Fri, 26 Jun 2026 12:24:57 -0400 Subject: Translated using Weblate (Galician) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/gl/ --- Emby.Server.Implementations/Localization/Core/gl.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index d3740130ee..a68db0076a 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -106,5 +106,6 @@ "TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización", "TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.", "CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios", - "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días." + "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.", + "Original": "Orixinal" } -- cgit v1.2.3