From 9569b4550d41084a3b7c20cfea454be870e0bf5f Mon Sep 17 00:00:00 2001 From: chasus Date: Sun, 24 May 2026 00:04:10 +1000 Subject: Fix Swagger UI auth docs (#12990) --- Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 2aadedfa61..6db0edb237 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API"); c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css"); c.RoutePrefix = "api-docs/swagger"; + c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }"""); }) .UseReDoc(c => { -- cgit v1.2.3 From e1a16b4ec64b0facf28b4cad9d6bf0808339461b Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Mon, 25 May 2026 11:35:38 -0400 Subject: Add XMLTV guide content ETags --- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 8 + .../Listings/XmlTvListingsProvider.cs | 24 ++- src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs | 184 +++++++++++++++++++++ .../Listings/XmlTvListingsProviderTests.cs | 57 +++++++ .../Listings/XmlTvProgramEtagTests.cs | 59 +++++++ .../Test Data/LiveTv/Listings/XmlTv/etag-base.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-category-change.xml | 17 ++ .../Listings/XmlTv/etag-description-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-icon-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-progid-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-reordered.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-title-change.xml | 17 ++ .../LiveTv/Listings/XmlTv/etag-unknown-field.xml | 18 ++ 13 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index 556516674b..d59eb9c18f 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Listings; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -497,6 +498,13 @@ public class GuideManager : IGuideManager item.TrySetProviderId(EtagKey, info.Etag); } + else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey))) + { + // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes, + // so an exact match means nothing relevant changed. Other providers stay on the + // field-by-field update path. + return (item, false, false); + } if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs index ec2e6cfcc9..622a1ac6fe 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -172,7 +172,29 @@ namespace Jellyfin.LiveTv.Listings var reader = new XmlTvReader(path, GetLanguage(info)); return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) - .Select(p => GetProgramInfo(p, info)); + .Select(p => GetProgramInfoWithEtag(p, info)); + } + + private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info) + { + var programInfo = GetProgramInfo(program, info); + + if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason)) + { + programInfo.Etag = etag; + } + else + { + _logger.LogDebug( + "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate} to {EndDate}: {Reason}. The program will be treated as updated on each guide refresh.", + programInfo.Id, + programInfo.ChannelId, + programInfo.StartDate, + programInfo.EndDate, + reason); + } + + return programInfo; } private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs new file mode 100644 index 0000000000..b128b0ff9c --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs @@ -0,0 +1,184 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.Listings +{ + internal static class XmlTvProgramEtag + { + internal const string Prefix = "xmltv-sha256-v1:"; + + internal static bool IsXmlTvEtag(string? etag) + => !string.IsNullOrWhiteSpace(etag) + && etag.StartsWith(Prefix, StringComparison.Ordinal); + + // Returns true only when the incoming etag is XMLTV-style AND equals the stored value. + // The IsXmlTvEtag gate keeps other providers (e.g. Schedules Direct) on the + // field-by-field update path even if their etag strings happen to match. + internal static bool MatchesStored(string? incomingEtag, string? storedEtag) + => IsXmlTvEtag(incomingEtag) + && string.Equals(incomingEtag, storedEtag, StringComparison.OrdinalIgnoreCase); + + internal static bool TryCreate(ProgramInfo programInfo, out string? etag, out string? reason) + { + etag = null; + + if (string.IsNullOrWhiteSpace(programInfo.Id)) + { + reason = "program id is empty"; + return false; + } + + if (string.IsNullOrWhiteSpace(programInfo.ChannelId)) + { + reason = "channel id is empty"; + return false; + } + + if (programInfo.StartDate == default) + { + reason = "start date is empty"; + return false; + } + + if (programInfo.EndDate == default) + { + reason = "end date is empty"; + return false; + } + + if (programInfo.EndDate <= programInfo.StartDate) + { + reason = "end date is not after start date"; + return false; + } + + var builder = new StringBuilder(1024); + + // Keep this list aligned with the ProgramInfo fields consumed by GuideManager. + AppendValue(builder, "schema", "xmltv-programinfo-v1"); + AppendValue(builder, nameof(programInfo.Id), programInfo.Id); + AppendValue(builder, nameof(programInfo.ChannelId), programInfo.ChannelId); + AppendValue(builder, nameof(programInfo.Name), programInfo.Name); + AppendValue(builder, nameof(programInfo.OfficialRating), programInfo.OfficialRating); + AppendValue(builder, nameof(programInfo.Overview), programInfo.Overview); + AppendValue(builder, nameof(programInfo.StartDate), programInfo.StartDate); + AppendValue(builder, nameof(programInfo.EndDate), programInfo.EndDate); + AppendList(builder, nameof(programInfo.Genres), programInfo.Genres); + AppendValue(builder, nameof(programInfo.OriginalAirDate), programInfo.OriginalAirDate); + AppendValue(builder, nameof(programInfo.IsHD), programInfo.IsHD); + AppendValue(builder, nameof(programInfo.Audio), programInfo.Audio?.ToString()); + AppendValue(builder, nameof(programInfo.CommunityRating), programInfo.CommunityRating); + AppendValue(builder, nameof(programInfo.IsRepeat), programInfo.IsRepeat); + AppendValue(builder, nameof(programInfo.EpisodeTitle), programInfo.EpisodeTitle); + AppendValue(builder, nameof(programInfo.ImagePath), programInfo.ImagePath); + AppendValue(builder, nameof(programInfo.ImageUrl), programInfo.ImageUrl); + AppendValue(builder, nameof(programInfo.ThumbImageUrl), programInfo.ThumbImageUrl); + AppendValue(builder, nameof(programInfo.LogoImageUrl), programInfo.LogoImageUrl); + AppendValue(builder, nameof(programInfo.BackdropImageUrl), programInfo.BackdropImageUrl); + AppendValue(builder, nameof(programInfo.IsMovie), programInfo.IsMovie); + AppendValue(builder, nameof(programInfo.IsSports), programInfo.IsSports); + AppendValue(builder, nameof(programInfo.IsSeries), programInfo.IsSeries); + AppendValue(builder, nameof(programInfo.IsLive), programInfo.IsLive); + AppendValue(builder, nameof(programInfo.IsNews), programInfo.IsNews); + AppendValue(builder, nameof(programInfo.IsKids), programInfo.IsKids); + AppendValue(builder, nameof(programInfo.IsPremiere), programInfo.IsPremiere); + AppendValue(builder, nameof(programInfo.ProductionYear), programInfo.ProductionYear); + AppendValue(builder, nameof(programInfo.SeriesId), programInfo.SeriesId); + AppendValue(builder, nameof(programInfo.ShowId), programInfo.ShowId); + AppendValue(builder, nameof(programInfo.SeasonNumber), programInfo.SeasonNumber); + AppendValue(builder, nameof(programInfo.EpisodeNumber), programInfo.EpisodeNumber); + AppendDictionary(builder, nameof(programInfo.ProviderIds), programInfo.ProviderIds); + AppendDictionary(builder, nameof(programInfo.SeriesProviderIds), programInfo.SeriesProviderIds); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + etag = Prefix + Convert.ToHexString(hash); + reason = null; + return true; + } + + private static void AppendValue(StringBuilder builder, string name, string? value) + { + builder.Append(name).Append('|'); + if (value is null) + { + builder.Append('N').Append("|0|"); + } + else + { + builder.Append('S') + .Append('|') + .Append(value.Length.ToString(CultureInfo.InvariantCulture)) + .Append('|') + .Append(value); + } + + builder.Append('\n'); + } + + private static void AppendValue(StringBuilder builder, string name, DateTime value) + => AppendValue(builder, name, FormatDateTime(value)); + + private static void AppendValue(StringBuilder builder, string name, DateTime? value) + => AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null); + + // Treat Unspecified as UTC so the etag does not vary with the server's local timezone. + private static string FormatDateTime(DateTime value) + { + var utc = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc), + _ => value.ToUniversalTime(), + }; + + return utc.ToString("O", CultureInfo.InvariantCulture); + } + + private static void AppendValue(StringBuilder builder, string name, bool value) + => AppendValue(builder, name, value ? "true" : "false"); + + private static void AppendValue(StringBuilder builder, string name, bool? value) + => AppendValue(builder, name, value switch { true => "true", false => "false", null => null }); + + private static void AppendValue(StringBuilder builder, string name, int? value) + => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture)); + + private static void AppendValue(StringBuilder builder, string name, float? value) + => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture)); + + private static void AppendList(StringBuilder builder, string name, IReadOnlyList values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + for (var i = 0; i < values.Count; i++) + { + AppendValue(builder, $"{name}[{i}]", values[i]); + } + } + + private static void AppendDictionary(StringBuilder builder, string name, IReadOnlyDictionary values) + { + AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); + if (values.Count == 0) + { + return; + } + + var index = 0; + foreach (var (key, value) in values + .OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Key, StringComparer.Ordinal)) + { + AppendValue(builder, $"{name}[{index}].Key", key); + AppendValue(builder, $"{name}[{index}].Value", value); + index++; + } + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs index b71dc15201..f698edc637 100644 --- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; using Moq; using Moq.Protected; @@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests Assert.True(program.HasImage); Assert.Equal("https://domain.tld/image.png", program.ImageUrl); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); } [Theory] @@ -85,5 +87,60 @@ public class XmlTvListingsProviderTests var program = programsList[0]; Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); Assert.Equal("3297", program.ChannelId); + AssertXmlTvEtag(program.Etag); + } + + [Fact] + public async Task GetProgramsAsync_Etag_SameContentIsStable() + { + var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + + Assert.Equal(first.Etag, second.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")] + public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var changed = await GetSingleProgramAsync(changedPath); + + Assert.NotEqual(original.Etag, changed.Etag); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")] + [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")] + public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath) + { + var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml"); + var equivalent = await GetSingleProgramAsync(equivalentPath); + + Assert.Equal(original.Etag, equivalent.Etag); + } + + private async Task GetSingleProgramAsync(string path) + { + var info = new ListingsProviderInfo() + { + Id = Path.GetFileNameWithoutExtension(path), + Path = path + }; + + var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + + return Assert.Single(programs.ToList()); + } + + private static void AssertXmlTvEtag(string? etag) + { + Assert.NotNull(etag); + Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal); } } diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs new file mode 100644 index 0000000000..b8d1c60e1a --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs @@ -0,0 +1,59 @@ +using System; +using Jellyfin.LiveTv.Listings; +using MediaBrowser.Controller.LiveTv; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class XmlTvProgramEtagTests +{ + [Fact] + public void TryCreate_GenreOrderIsSignificant() + { + // GuideManager assigns item.Genres = info.Genres.ToArray() preserving order, + // so the same genres in a different order is a real mapped-content change. + var first = NewProgram(); + first.Genres = new() { "Drama", "Action" }; + + var second = NewProgram(); + second.Genres = new() { "Action", "Drama" }; + + Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _)); + Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _)); + Assert.NotEqual(firstEtag, secondEtag); + } + + [Fact] + public void MatchesStored_EqualXmlTvEtags_ReturnsTrue() + { + const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789"; + Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + [Fact] + public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse() + { + Assert.False(XmlTvProgramEtag.MatchesStored( + XmlTvProgramEtag.Prefix + "AAAA", + XmlTvProgramEtag.Prefix + "BBBB")); + } + + [Fact] + public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse() + { + // Other providers (e.g. Schedules Direct) use their own etag schemes. + // The IsXmlTvEtag gate must keep them on the field-by-field update path + // even when their incoming and stored values happen to match exactly. + const string Etag = "sd-abc123"; + Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag)); + } + + private static ProgramInfo NewProgram() => new() + { + Id = "program-id", + ChannelId = "channel-id", + Name = "Program Name", + StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc), + }; +} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml new file mode 100644 index 0000000000..15f85f57e6 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml new file mode 100644 index 0000000000..2b49c3bccd --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + sports + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml new file mode 100644 index 0000000000..090273ac98 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Changed description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml new file mode 100644 index 0000000000..532b91da20 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml new file mode 100644 index 0000000000..db0d5e86de --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml @@ -0,0 +1,17 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789013 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml new file mode 100644 index 0000000000..168c0a643b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml @@ -0,0 +1,17 @@ + + + + + 3/5 + + + TV-G + + 0 . 1 . + EP123456789012 + series + Base description. + Base Episode + Base Program + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml new file mode 100644 index 0000000000..73288e7c57 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml @@ -0,0 +1,17 @@ + + + Changed Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml new file mode 100644 index 0000000000..d0ff1b82f5 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml @@ -0,0 +1,18 @@ + + + Base Program + Base Episode + Base description. + series + 0 . 1 . + EP123456789012 + + TV-G + + + 3/5 + + Ignored by Jellyfin XMLTV mapping. + + + -- cgit v1.2.3 From e1d63c0ea09f542f3e0432a641153591b579cb37 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Mon, 25 May 2026 17:36:25 -0400 Subject: Fixed issue etag info was not being set until the 3rd time though processing due to the "isNew" condition. Etag wouldn't be saved/persistent until the 3rd time through and onward. Without this change it's self healing after the 3rd cycle. It also appears there may be an issue with this etag "skip if hash hasn't changed" for schedules direct functionality.... like it never will work. But out of scope here. Also fixed Sonar gripes about code formatting --- src/Jellyfin.LiveTv/Guide/GuideManager.cs | 19 ++++++++++--------- src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs | 24 ++++++++++++------------ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs index d59eb9c18f..e0d9b323be 100644 --- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -495,8 +495,6 @@ public class GuideManager : IGuideManager DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - - item.TrySetProviderId(EtagKey, info.Etag); } else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey))) { @@ -629,13 +627,9 @@ public class GuideManager : IGuideManager forceUpdate |= UpdateImages(item, info); - if (isNew) - { - item.OnMetadataChanged(); - - return (item, true, false); - } - + // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and + // persist it on new items so they join the fast path on the next refresh + // instead of taking an extra full processing cycle. var isUpdated = forceUpdate; var etag = info.Etag; if (string.IsNullOrWhiteSpace(etag)) @@ -648,6 +642,13 @@ public class GuideManager : IGuideManager isUpdated = true; } + if (isNew) + { + item.OnMetadataChanged(); + + return (item, true, false); + } + if (isUpdated) { item.OnMetadataChanged(); diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs index b128b0ff9c..b5ddb1530f 100644 --- a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs +++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs @@ -128,6 +128,18 @@ namespace Jellyfin.LiveTv.Listings private static void AppendValue(StringBuilder builder, string name, DateTime? value) => AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null); + private static void AppendValue(StringBuilder builder, string name, bool value) + => AppendValue(builder, name, value ? "true" : "false"); + + private static void AppendValue(StringBuilder builder, string name, bool? value) + => AppendValue(builder, name, value switch { true => "true", false => "false", null => null }); + + private static void AppendValue(StringBuilder builder, string name, int? value) + => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture)); + + private static void AppendValue(StringBuilder builder, string name, float? value) + => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture)); + // Treat Unspecified as UTC so the etag does not vary with the server's local timezone. private static string FormatDateTime(DateTime value) { @@ -141,18 +153,6 @@ namespace Jellyfin.LiveTv.Listings return utc.ToString("O", CultureInfo.InvariantCulture); } - private static void AppendValue(StringBuilder builder, string name, bool value) - => AppendValue(builder, name, value ? "true" : "false"); - - private static void AppendValue(StringBuilder builder, string name, bool? value) - => AppendValue(builder, name, value switch { true => "true", false => "false", null => null }); - - private static void AppendValue(StringBuilder builder, string name, int? value) - => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture)); - - private static void AppendValue(StringBuilder builder, string name, float? value) - => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture)); - private static void AppendList(StringBuilder builder, string name, IReadOnlyList values) { AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture)); -- cgit v1.2.3 From c49f279a27243fd2a8464240292a3ded713f2e4b Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Sun, 21 Jun 2026 18:22:00 -0400 Subject: Fix Identify returning wrong images --- MediaBrowser.Providers/Manager/MetadataService.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 118ccf8679..60eda1628c 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager return providers; } - protected virtual IEnumerable GetNonLocalImageProviders(BaseItem item, IEnumerable allImageProviders, ImageRefreshOptions options) + protected virtual IEnumerable GetNonLocalImageProviders(BaseItem item, IEnumerable allImageProviders, MetadataRefreshOptions options) { // Get providers to refresh var providers = allImageProviders.Where(i => i is not ILocalImageProvider); + // When identifying, run the provider the user picked first so the correct image is used. + if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName)) + { + providers = providers + .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1); + } + var dateLastImageRefresh = item.DateLastRefreshed; // Run all if either of these flags are true -- cgit v1.2.3 From 617ebf367ff24ecbe8e0de5aebc90fe28689bcb0 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Wed, 1 Apr 2026 16:55:47 -0500 Subject: Fix path transversal exposure in Plugins The request path is not validated to a valid path and could allow escaping the transcode path and downloading of any arbitrary file in GetHlsPlaylistLegacy . GetHlsAudioSegmentLegacy and GetHlsVideoSegmentLegacy have the same issue, and are NOT behind an Authorize so they are publicly exploitable. Added a ValidateTranscodePath that verifies that requested file paths start with the transcode path setting. Also ensure that all filename comparisons are OrdinalIgnoreCase because we might be running on a filesystem where filename-casing doesn't have to match. Switched from InvariantCulture because the underlying OS filename comparisons are always byte-wise (with case insensitivity here). Fixed a similar issue in GetPluginImage --- Jellyfin.Api/Controllers/HlsSegmentController.cs | 42 +++++++++++++----------- Jellyfin.Api/Controllers/PluginsController.cs | 7 ++-- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index b5365cd632..4cb7627559 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) { // TODO: Deprecate with new iOS app - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } @@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) { - var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) - || Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) + var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null + || !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) { return BadRequest("Invalid segment."); } @@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController [FromRoute, Required] string segmentId, [FromRoute, Required] string segmentContainer) { - var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); - var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); - - file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) + var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()))); + if (file is null) { return BadRequest("Invalid segment."); } - var normalizedPlaylistId = playlistId; - + var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); // Add . to start of segment container for future use. segmentContainer = segmentContainer.Insert(0, "."); @@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController var pathExtension = Path.GetExtension(path); if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) - && path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase)) + && path.Contains(playlistId, StringComparison.OrdinalIgnoreCase)) { playlistPath = path; break; @@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController : GetFileResult(file, playlistPath); } + private string? ValidateTranscodePath(string filename) + { + var transcodePath = _serverConfigurationManager.GetTranscodePath(); + var file = Path.GetFullPath(filename, transcodePath); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return file; + } + private ActionResult GetFileResult(string path, string playlistPath) { var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 0105ecf7a7..5f6136ed11 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -226,10 +226,11 @@ public class PluginsController : BaseJellyfinApiController return NotFound(); } - if (!string.IsNullOrEmpty(plugin.Manifest.ImagePath)) + string? imagePath = plugin.Manifest.ImagePath; + if (!string.IsNullOrWhiteSpace(imagePath)) { - var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); - if (!System.IO.File.Exists(imagePath)) + imagePath = Path.GetFullPath(imagePath, plugin.Path); + if (imagePath.StartsWith(plugin.Path, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false) { return NotFound(); } -- cgit v1.2.3 From 95cebffa8782e8ce8dc48a15c9723ce9f33cb09c Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Sun, 28 Jun 2026 16:26:35 -0500 Subject: Add tests Also fixed a sibling directory that matches the prefix. --- Jellyfin.Api/Controllers/HlsSegmentController.cs | 6 +- Jellyfin.Api/Controllers/PluginsController.cs | 6 +- .../Controllers/HlsSegmentControllerTests.cs | 161 +++++++++++++++++++++ .../Controllers/PluginsControllerTests.cs | 129 +++++++++++++++++ 4 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs create mode 100644 tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 4cb7627559..c61ee45830 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -164,10 +164,10 @@ public class HlsSegmentController : BaseJellyfinApiController private string? ValidateTranscodePath(string filename) { - var transcodePath = _serverConfigurationManager.GetTranscodePath(); + var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath())); var file = Path.GetFullPath(filename, transcodePath); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.OrdinalIgnoreCase)) + // Require a separator after the transcode path so a sibling like "-evil" can't pass. + if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { return null; } diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 5f6136ed11..2c84fde972 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -229,8 +229,10 @@ public class PluginsController : BaseJellyfinApiController string? imagePath = plugin.Manifest.ImagePath; if (!string.IsNullOrWhiteSpace(imagePath)) { - imagePath = Path.GetFullPath(imagePath, plugin.Path); - if (imagePath.StartsWith(plugin.Path, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false) + var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path)); + imagePath = Path.GetFullPath(imagePath, pluginPath); + // Require a separator after the plugin path so a sibling like "-evil" can't pass. + if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false) { return NotFound(); } diff --git a/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs new file mode 100644 index 0000000000..a248664928 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio +// and video segment endpoints are not authenticated. These tests pin down that requests escaping +// the transcode directory are rejected while legitimate ones still serve a file. +public sealed class HlsSegmentControllerTests +{ + private readonly Mock _fileSystem = new(); + private readonly Mock _config = new(); + private readonly Mock _transcodeManager = new(); + private readonly string _transcodePath; + + public HlsSegmentControllerTests() + { + _transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests"); + Directory.CreateDirectory(_transcodePath); + + _config.Setup(c => c.GetConfiguration("encoding")) + .Returns(new EncodingOptions { TranscodingTempPath = _transcodePath }); + _config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of()); + } + + private HlsSegmentController CreateController(string requestPath) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = requestPath; + + return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [Fact] + public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + var result = controller.GetHlsAudioSegmentLegacy("abc", "segment"); + + Assert.IsType(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + [InlineData("subdir/../../../../etc/passwd")] + public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId) + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId); + + Assert.IsType(result); + } + + [Fact] + public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + // A rooted segment id makes Path.GetFullPath discard the transcode base. + var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd"; + var result = controller.GetHlsAudioSegmentLegacy("abc", rooted); + + Assert.IsType(result); + } + + [Fact] + public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest() + { + var controller = CreateController("/Audio/abc/hls/segment/stream.mp3"); + + // Resolves to "-evil/passwd", which shares the transcode path as a string prefix. + var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd"); + + Assert.IsType(result); + } + + [Fact] + public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile() + { + var controller = CreateController("/Videos/abc/hls/list/stream.m3u8"); + + var result = controller.GetHlsPlaylistLegacy("abc", "list"); + + Assert.IsType(result); + } + + [Fact] + public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest() + { + // Playlist endpoint serves only .m3u8, even for a path inside the transcode dir. + var controller = CreateController("/Videos/abc/hls/list/stream.mp4"); + + var result = controller.GetHlsPlaylistLegacy("abc", "list"); + + Assert.IsType(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId) + { + var controller = CreateController("/Videos/abc/hls/list/stream.m3u8"); + + var result = controller.GetHlsPlaylistLegacy("abc", playlistId); + + Assert.IsType(result); + } + + [Fact] + public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile() + { + _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false)) + .Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") }); + + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts"); + + Assert.IsType(result); + } + + [Fact] + public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound() + { + _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false)) + .Returns(Array.Empty()); + + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts"); + + Assert.IsType(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId) + { + var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts"); + + var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts"); + + Assert.IsType(result); + _fileSystem.Verify(f => f.GetFilePaths(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs new file mode 100644 index 0000000000..f040a328bb --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Updates; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath +// must resolve to a file inside the plugin's own directory. +public sealed class PluginsControllerTests +{ + private readonly Mock _pluginManager = new(); + private readonly string _pluginPath; + + public PluginsControllerTests() + { + _pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests"); + Directory.CreateDirectory(_pluginPath); + } + + private PluginsController CreateController() => + new PluginsController(Mock.Of(), _pluginManager.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + private void SetupPlugin(Guid id, Version version, string? imagePath) + { + var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath }; + _pluginManager.Setup(p => p.GetPlugin(id, version)) + .Returns(new LocalPlugin(_pluginPath, true, manifest)); + } + + [Fact] + public void GetPluginImage_UnknownPlugin_ReturnsNotFound() + { + var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0)); + + Assert.IsType(result); + } + + [Fact] + public void GetPluginImage_ImageInsidePluginPath_ReturnsFile() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty()); + SetupPlugin(id, version, "logo.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType(result); + } + + [Fact] + public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, "does-not-exist.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType(result); + } + + [Theory] + [InlineData("../../../../etc/passwd")] + [InlineData("subdir/../../../../etc/passwd")] + public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath) + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, imagePath); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType(result); + } + + [Fact] + public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + // Resolves to "-evil/logo.png", which shares the plugin path as a string prefix. + // The file is created so the check fails on the boundary, not on File.Exists. + var siblingDir = _pluginPath + "-evil"; + Directory.CreateDirectory(siblingDir); + File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty()); + SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType(result); + } + + [Fact] + public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound() + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd"); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath) + { + var id = Guid.NewGuid(); + var version = new Version(1, 0); + SetupPlugin(id, version, imagePath); + + var result = CreateController().GetPluginImage(id, version); + + Assert.IsType(result); + } +} -- cgit v1.2.3 From 22792b62cb6fe22a50431eb4e24723b3a93a4bc9 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Mon, 29 Jun 2026 12:04:55 -0400 Subject: Sort trailers for TV Shows --- .../Plugins/Tmdb/TV/TmdbSeriesProvider.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 1eb411f0f6..4229a1dc82 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -320,13 +320,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (seriesResult.Videos?.Results is not null) { - foreach (var video in seriesResult.Videos.Results) + var trailers = new List(); + + var sortedVideos = seriesResult.Videos.Results + .OrderByDescending(video => string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase)); + + foreach (var video in sortedVideos) { - if (TmdbUtils.IsTrailerType(video)) + if (!TmdbUtils.IsTrailerType(video)) { - series.AddTrailerUrl("https://www.youtube.com/watch?v=" + video.Key); + continue; } + + trailers.Add(new MediaUrl + { + Url = string.Format(CultureInfo.InvariantCulture, "https://www.youtube.com/watch?v={0}", video.Key), + Name = video.Name + }); } + + series.RemoteTrailers = trailers; } if (!string.IsNullOrEmpty(seriesResult.OriginalLanguage)) -- cgit v1.2.3 From ab0d0d1890fa44a827218c890bd5d7af23aacf51 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Mon, 6 Jul 2026 22:06:34 -0400 Subject: Fix artists being displayed with albums --- Jellyfin.Api/Extensions/DtoExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 09793553c2..36a8e197a2 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -24,7 +24,7 @@ public static class DtoExtensions case CollectionType.tvshows: return [BaseItemKind.Series]; case CollectionType.music: - return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist]; + return [BaseItemKind.MusicAlbum]; case CollectionType.musicvideos: return [BaseItemKind.MusicVideo]; case CollectionType.books: -- cgit v1.2.3 From 1294990f4fa93b30ec9699d18af4de1cd1d562b8 Mon Sep 17 00:00:00 2001 From: Jakub Schmidtke Date: Tue, 7 Jul 2026 04:18:02 +0200 Subject: Added more aliases for attributes Adds tvdb alias for tvdbid and imdb alias for imdbid. It also fixes an issue where tmdb alias was being ignored if it was followed by something like "tmdbidfoo". The same issue prevented imdb pattern matching from working, if it was followed by something like "imdbidfoo". It also allows for detecting the first matching occurence, whether it was an alias or not. Finally, it ignores attributes with values consisting of only whitespaces. --- .../Library/PathExtensions.cs | 81 +++++++++++++++------- .../Library/PathExtensionsTests.cs | 63 ++++++++++++++++- 2 files changed, 118 insertions(+), 26 deletions(-) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 7591359ea4..8815e2785a 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("String can't be empty.", nameof(attribute)); } - var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc. + // The code below only supports aliases for attributes in the form of "id". + ReadOnlySpan shortAttr = attribute switch + { + _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb", + _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb", + _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb", + _ => string.Empty + }; - // Must be at least 3 characters after the attribute =, ], any character, - // then we offset it by 1, because we want the index and not length. - var maxIndex = str.Length - attribute.Length - 2; - while (attributeIndex > -1 && attributeIndex < maxIndex) + for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;) { - var attributeEnd = attributeIndex + attribute.Length; + // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'. + var subStr = str[strIndex..]; + int attributeEnd = 0; + + if (shortAttr.Length > 0) + { + // If we are using an alias it should be shorter (and a prefix), so let's search for that. + attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + shortAttr.Length; + } + else + { + attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + attributeEnd = attributeIndex + attribute.Length; + } + + // The next iteration should start at the end of the attribute we just found. + // If attributeIndex < 0, the loop will end and strIndex won't be used again. + strIndex += attributeEnd; + if (attributeIndex > 0) { - var attributeOpener = str[attributeIndex - 1]; + var attributeOpener = subStr[attributeIndex - 1]; var attributeCloser = attributeOpener switch { '[' => ']', @@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library '{' => '}', _ => '\0' }; - if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-')) + + if (attributeCloser != '\0') { - var closingIndex = str[attributeEnd..].IndexOf(attributeCloser); + if (shortAttr.Length > 0 + && attributeEnd + 1 < subStr.Length + && (subStr[attributeEnd] is 'i' or 'I') + && (subStr[attributeEnd + 1] is 'd' or 'D')) + { + // We were searching for a shortened attribute, but it's followed by "id" - let's skip it. + attributeEnd += 2; + } - // Must be at least 1 character before the closing bracket. - if (closingIndex > 1) + // attributeEnd points at '='. + // We need at least 1 more character and the closing bracket after that. + if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-')) { - return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser); + + // Must be at least 1 character before the closing bracket. + if (closingIndex > 1) + { + var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim(); + + if (trimmed.Length > 0) + { + return trimmed.ToString(); + } + } } } } - - str = str[attributeEnd..]; - attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); } // for imdbid we also accept pattern matching @@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library return match ? imdbId.ToString() : null; } - // Allow tmdb as an alias for tmdbid - if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase)) - { - var tmdbValue = str.GetAttributeValue("tmdb"); - if (tmdbValue is not null) - { - return tmdbValue; - } - } - return null; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index 650d67b195..e65bc1d31f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library { [Theory] [InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son", "imdbid", null)] [InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")] [InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")] [InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")] + [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")] [InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")] [InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")] [InlineData("Superman: Red Son [providera id=4]", "providera id", "4")] [InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")] + [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")] [InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")] - [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")] + [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")] [InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")] + [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")] + [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")] + [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")] + [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")] [InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")] + [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")] [InlineData("[tmdbid=618355]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]", "tmdbid", "618355")] [InlineData("{tmdbid=618355}", "tmdbid", "618355")] + [InlineData("{tmdb=618355}", "tmdbid", "618355")] [InlineData("(tmdbid=618355)", "tmdbid", "618355")] + [InlineData("(tmdb=618355)", "tmdbid", "618355")] [InlineData("[tmdbid-618355]", "tmdbid", "618355")] + [InlineData("[tmdb-618355]", "tmdbid", "618355")] [InlineData("{tmdbid-618355)", "tmdbid", null)] + [InlineData("{tmdb-618355)", "tmdbid", null)] [InlineData("[tmdbid-618355}", "tmdbid", null)] + [InlineData("[tmdb-618355}", "tmdbid", null)] [InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")] + [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")] [InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")] + [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")] [InlineData("tmdbid=618355]", "tmdbid", null)] + [InlineData("tmdb=618355]", "tmdbid", null)] [InlineData("[tmdbid=618355", "tmdbid", null)] + [InlineData("[tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=618355", "tmdbid", null)] + [InlineData("tmdb=618355", "tmdbid", null)] [InlineData("tmdbid=", "tmdbid", null)] + [InlineData("tmdb=", "tmdbid", null)] [InlineData("tmdbid", "tmdbid", null)] + [InlineData("tmdb", "tmdbid", null)] + [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")] + [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")] [InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)] + [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)] [InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)] + [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)] [InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")] [InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)] + [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)] [InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)] + [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)] [InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")] + [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")] + [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")] public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult) { Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute)); -- cgit v1.2.3 From fff710585f924ce8c868e6706373155a3bcf6444 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Tue, 7 Jul 2026 10:03:17 -0400 Subject: Restore music collection image override --- Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index b701e7eb6d..7cae2a671b 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -31,6 +31,12 @@ namespace Emby.Server.Implementations.Images var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType); var recursive = viewType != CollectionType.playlists; + if (viewType == CollectionType.music) + { + // Music albums usually don't have dedicated backdrops, so use artist instead + includeItemTypes = [BaseItemKind.MusicArtist]; + } + return view.GetItemList(new InternalItemsQuery { CollapseBoxSetItems = false, -- cgit v1.2.3 From 08f6627a24083288a1450c7af62128371b634454 Mon Sep 17 00:00:00 2001 From: Jakub Schmidtke Date: Wed, 8 Jul 2026 01:40:26 +0200 Subject: Replaced string.Empty with ReadOnlySpan.Empty --- Emby.Server.Implementations/Library/PathExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 8815e2785a..7d0f3900c5 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -36,7 +36,7 @@ namespace Emby.Server.Implementations.Library _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb", _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb", _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb", - _ => string.Empty + _ => ReadOnlySpan.Empty }; for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;) -- cgit v1.2.3 From 853922443fdd56f6467349bbf7db5d0dc29f570e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 9 Jul 2026 11:54:18 +0200 Subject: Remove episode image override hack --- Emby.Server.Implementations/Dto/DtoService.cs | 16 ------------ .../Dto/DtoServiceTests.cs | 29 ++++++++++++++-------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 5c76c4014a..c3a9253f29 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1392,31 +1392,15 @@ namespace Emby.Server.Implementations.Dto ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) : null; - BaseItem? posterParent = null; if (seasonPrimaryTag is not null) { dto.ParentPrimaryImageItemId = episodeSeason!.Id; dto.ParentPrimaryImageTag = seasonPrimaryTag; - posterParent = episodeSeason; } else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) { dto.ParentPrimaryImageItemId = episodeSeries.Id; dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; - posterParent = episodeSeries; - } - - if (posterParent is not null) - { - if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag)) - { - // Only drop the episode's own primary blurhash; keep the poster parent's. - dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag); - } - - dto.SeriesPrimaryImageTag = null; - dto.PrimaryImageAspectRatio = null; - AttachPrimaryImageAspectRatio(dto, posterParent); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index a5de0a4416..4e5790012f 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -11,6 +11,7 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -56,20 +57,26 @@ public class DtoServiceTests } [Fact] - public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries() + public void GetBaseItemDto_PreferEpisodeParentPoster_AttachesSeasonPosterWithoutDroppingEpisodeImage() { - var (episode, season, series) = BuildEpisode(seasonHasPoster: true); - var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + var (episode, season, _) = BuildEpisode(seasonHasPoster: true); + var options = new DtoOptions(false) + { + PreferEpisodeParentPoster = true, + Fields = [ItemFields.PrimaryImageAspectRatio] + }; var dto = _dtoService.GetBaseItemDto(episode, options); - // The episode's own 16:9 primary is dropped in favor of the season's portrait poster. - Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary)); - Assert.Null(dto.SeriesPrimaryImageTag); + // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio, + // and clients decide per view whether to prefer the parent/series poster over the episode still. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); Assert.Equal(season.Id, dto.ParentPrimaryImageItemId); Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); - // Aspect ratio follows the (portrait) poster, not the episode's 16:9 image. - Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio); + // Aspect ratio stays the episode's own image, not the poster's. + Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio); } [Fact] @@ -80,8 +87,10 @@ public class DtoServiceTests var dto = _dtoService.GetBaseItemDto(episode, options); - Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary)); - Assert.Null(dto.SeriesPrimaryImageTag); + // Episode image is retained; ParentPrimaryImage falls back to the series poster. + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.NotNull(dto.SeriesPrimaryImageTag); Assert.Equal(series.Id, dto.ParentPrimaryImageItemId); Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag); } -- cgit v1.2.3 From 38813f7d4288813ad8e7582a87d14daa6a129852 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 9 Jul 2026 12:07:13 +0200 Subject: Cleanup PreferEpisodeParentPoster) --- Emby.Server.Implementations/Dto/DtoService.cs | 2 +- Jellyfin.Api/Controllers/UserLibraryController.cs | 2 -- MediaBrowser.Controller/Dto/DtoOptions.cs | 7 ------ .../Dto/DtoServiceTests.cs | 26 ++++++++++------------ 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index c3a9253f29..8cbf42585d 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1385,7 +1385,7 @@ namespace Emby.Server.Implementations.Dto } } - if (options.PreferEpisodeParentPoster) + if (options.GetImageLimit(ImageType.Primary) > 0) { var episodeSeason = episode.Season; var seasonPrimaryTag = episodeSeason is not null diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 25f781e496..a718035528 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -551,8 +551,6 @@ public class UserLibraryController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - dtoOptions.PreferEpisodeParentPoster = true; - var list = _userViewManager.GetLatestItems( new LatestItemsQuery { diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index d319feb6b2..052626355f 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -81,13 +81,6 @@ namespace MediaBrowser.Controller.Dto /// public bool AddCurrentProgram { get; set; } - /// - /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary - /// image, falling back to the series') should replace the episode's own (16:9) primary image. - /// Used by views that render episodes as poster cards, e.g. "Latest". - /// - public bool PreferEpisodeParentPoster { get; set; } - /// /// Gets a value indicating whether the specified field is populated. /// diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index 4e5790012f..9c247d54b9 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -57,14 +57,10 @@ public class DtoServiceTests } [Fact] - public void GetBaseItemDto_PreferEpisodeParentPoster_AttachesSeasonPosterWithoutDroppingEpisodeImage() + public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage() { var (episode, season, _) = BuildEpisode(seasonHasPoster: true); - var options = new DtoOptions(false) - { - PreferEpisodeParentPoster = true, - Fields = [ItemFields.PrimaryImageAspectRatio] - }; + var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] }; var dto = _dtoService.GetBaseItemDto(episode, options); @@ -80,10 +76,10 @@ public class DtoServiceTests } [Fact] - public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster() + public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster() { var (episode, _, series) = BuildEpisode(seasonHasPoster: false); - var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + var options = new DtoOptions(false); var dto = _dtoService.GetBaseItemDto(episode, options); @@ -96,26 +92,28 @@ public class DtoServiceTests } [Fact] - public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary() + public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary() { - var (episode, _, _) = BuildEpisode(seasonHasPoster: true); + var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false); var options = new DtoOptions(false); var dto = _dtoService.GetBaseItemDto(episode, options); - // Default behavior: the episode keeps its own primary and exposes the series poster as a tag. + // With no season or series poster there is nothing to attach; the episode keeps its own primary. Assert.NotNull(dto.ImageTags); Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); - Assert.NotNull(dto.SeriesPrimaryImageTag); Assert.Null(dto.ParentPrimaryImageItemId); } - private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster) + private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true) { // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the // item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode. var series = new Series { Id = Guid.NewGuid(), Name = "Series" }; - series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0); + if (seriesHasPoster) + { + series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0); + } var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id }; if (seasonHasPoster) -- cgit v1.2.3 From 2326ecdedc042f7f1c2ce2a5da409377c09778d7 Mon Sep 17 00:00:00 2001 From: TowyTowy Date: Thu, 9 Jul 2026 13:39:09 +0200 Subject: Fix profile image being impossible to clear when its in-memory key is temporary ClearProfileImageAsync removed the ProfileImage instance attached to the passed-in User, but that instance can carry a stale, never-persisted (temporary) key because UpdateUserAsync creates the persisted image on a separately loaded entity and never copies the generated key back. Removing that detached entity on a fresh DbContext made EF Core throw InvalidOperationException ('ImageInfo.Id has a temporary value'), leaving the profile image impossible to delete or replace. Load the tracked, persisted user and remove its actual ProfileImage, matching the removal pattern already used in UpdateUserAsync. Adds regression tests covering the temporary-key case and the no-image no-op (the first fails before this change and passes after). Fixes #13137 Co-Authored-By: Claude Fable 5 --- .../Users/UserManager.cs | 16 ++- .../Users/UserManagerProfileImageTests.cs | 142 +++++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 80722af106..b42fae751a 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -882,8 +882,20 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + // Remove the tracked profile image loaded from the database instead of the + // detached instance on the passed in user. That instance can carry a stale, + // never-persisted (temporary) key, which makes EF Core throw when it is marked + // for deletion, leaving the profile image impossible to clear or replace. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false); + if (dbUser?.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } user.ProfileImage = null; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs new file mode 100644 index 0000000000..cb714a4014 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Cryptography; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public sealed class UserManagerProfileImageTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; + private readonly UserManager _userManager; + + public UserManagerProfileImageTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock(); + var configManager = new Mock(); + var appPaths = new Mock(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock().Object, + appHost.Object, + new Mock().Object, + NullLogger.Instance, + configManager.Object, + new IPasswordResetProvider[] { defaultPasswordResetProvider }, + new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider }); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(null!, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); + } + + [Fact] + public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage() + { + var user = await _userManager.CreateUserAsync("profileimageuser"); + + // Assign a profile image the same way the image endpoint does and persist it. + // UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity, + // so the in-memory instance below is never assigned the database generated key. + user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png")); + await _userManager.UpdateUserAsync(user); + + // Precondition reproducing the bug: the in-memory image still carries the default, + // never-persisted (temporary) key, while a real image row exists in the database. + Assert.Equal(0, user.ProfileImage.Id); + Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage); + + // This used to throw InvalidOperationException: + // "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'." + var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user)); + + Assert.Null(exception); + Assert.Null(user.ProfileImage); + Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage); + } + + [Fact] + public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing() + { + var user = await _userManager.CreateUserAsync("noprofileimageuser"); + + var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user)); + + Assert.Null(exception); + Assert.Null(user.ProfileImage); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } + } +} -- cgit v1.2.3 From 631a314d24bd2c7e1b3e0b81aa65437586794ccd Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Fri, 10 Jul 2026 14:46:27 +0800 Subject: Fix potential garbled text in FFmpeg logs on Windows Explicitly set StandardErrorEncoding and StandardOutputEncoding to Encoding.UTF8 when invoking the FFmpeg subprocess. This prevents log encoding issues and character corruption on Windows environments that default to non-UTF8 ANSI code pages. This fixes garbled metadata and font names in the FFmpeg logs. Signed-off-by: nyanmisaka --- .../ScheduledTasks/Tasks/AudioNormalizationTask.cs | 3 ++- MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs | 3 +++ MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 2 ++ MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs | 1 + src/Jellyfin.LiveTv/IO/EncodedRecorder.cs | 1 + .../FfProbe/FfProbeKeyframeExtractor.cs | 2 ++ 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index b2dc89be28..e4939205c9 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask { FileName = _mediaEncoder.EncoderPath, Arguments = args, - RedirectStandardOutput = false, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true }, }) diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 68d6d215b2..c8670c67cc 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Globalization; using System.Linq; using System.Runtime.Versioning; +using System.Text; using System.Text.RegularExpressions; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; @@ -645,7 +646,9 @@ namespace MediaBrowser.MediaEncoding.Encoder WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false, RedirectStandardInput = redirectStandardIn, + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true } }) diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 66bf6ebd24..1199fd7d70 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; @@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder UseShellExecute = false, // Must consume both or ffmpeg may hang due to deadlocks. + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, FileName = _ffprobePath, diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index defd855ec0..78bb881ec2 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable // Must consume both stdout and stderr or deadlocks may occur // RedirectStandardOutput = true, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true, RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index d877a0d124..19c4514766 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO CreateNoWindow = true, UseShellExecute = false, + StandardErrorEncoding = Encoding.UTF8, RedirectStandardError = true, RedirectStandardInput = true, diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index cbe97a8210..af868e4bd6 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Text; namespace Jellyfin.MediaEncoding.Keyframes.FfProbe; @@ -31,6 +32,7 @@ public static class FfProbeKeyframeExtractor CreateNoWindow = true, UseShellExecute = false, + StandardOutputEncoding = Encoding.UTF8, RedirectStandardOutput = true, WindowStyle = ProcessWindowStyle.Hidden, -- cgit v1.2.3 From 2250015e789b5f5f9b3995bf71d37e42ab6727be Mon Sep 17 00:00:00 2001 From: dkanada Date: Fri, 10 Jul 2026 22:53:14 +0900 Subject: normalize common formats for creator names in OPF data --- .../Books/OpenPackagingFormat/OpfReader.cs | 26 +++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs index b4e3fd3089..6266413dfc 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Xml; using Jellyfin.Data.Enums; @@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat /// Methods used to pull metadata and other information from Open Packaging Format in XML objects. /// /// The type of category. - public class OpfReader + public partial class OpfReader { private const string DcNamespace = @"http://purl.org/dc/elements/1.1/"; private const string OpfNamespace = @"http://www.idpf.org/2007/opf"; @@ -42,6 +43,9 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat _namespaceManager.AddNamespace("opf", OpfNamespace); } + [GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")] + private static partial Regex InitialsRegex(); + /// /// Checks for the existence of a cover image. /// @@ -229,11 +233,23 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat { foreach (XmlElement creator in resultElement) { - var creatorName = creator.InnerText; var role = creator.GetAttribute("opf:role"); - var person = new PersonInfo { Name = creatorName, Type = GetRole(role) }; - - book.AddPerson(person); + var normalizedCreators = creator.InnerText + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(fullName => + { + if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is [var lastName, var firstName]) + { + fullName = $"{firstName} {lastName}"; + } + + return InitialsRegex().Replace(fullName, ". "); + }); + + foreach (var fullName in normalizedCreators) + { + book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) }); + } } } } -- cgit v1.2.3 From 47f567b6f1ad9729ad3721a5798cab03978300f5 Mon Sep 17 00:00:00 2001 From: Elian Van Cutsem Date: Sat, 11 Jul 2026 14:04:07 +0200 Subject: Keep authenticated user entity in sync with persisted login timestamps ExecuteUpdateAsync bypasses the EF change tracker, so the user entity returned by AuthenticateUser still carried the old LastLoginDate and LastActivityDate. SessionManager.LogSessionActivity then saved that stale entity in full, reverting LastLoginDate (usually to null) milliseconds after every login. Setting the properties on the entity keeps the follow-up save consistent and lets the 60-second activity guard skip the redundant write during login. Fixes #17301 --- Jellyfin.Server.Implementations/Users/UserManager.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 80722af106..a3e5f12382 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users .SetProperty(f => f.LastActivityDate, date) .SetProperty(f => f.LastLoginDate, date)) .ConfigureAwait(false); + + // ExecuteUpdateAsync bypasses the change tracker, so keep the + // returned entity in sync. Otherwise SessionManager.LogSessionActivity + // saves this (stale) entity in full and reverts LastLoginDate. + user.LastActivityDate = date; + user.LastLoginDate = date; } await dbContext.Users -- cgit v1.2.3 From 9e996d612c3ee6779db8802a4c3eb3bcd09fe9c9 Mon Sep 17 00:00:00 2001 From: dkanada Date: Sun, 12 Jul 2026 19:46:56 +0900 Subject: extract page count from archives and PDFs --- Directory.Packages.props | 1 + MediaBrowser.Controller/Entities/Book.cs | 5 -- MediaBrowser.Providers/Manager/MetadataService.cs | 2 +- .../MediaBrowser.Providers.csproj | 1 + MediaBrowser.Providers/MediaInfo/ProbeProvider.cs | 54 ++++++++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f0a655d488..17add2f9c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -53,6 +53,7 @@ + diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs index 5187669373..8559681bdc 100644 --- a/MediaBrowser.Controller/Entities/Book.cs +++ b/MediaBrowser.Controller/Entities/Book.cs @@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities [Common.RequiresSourceSerialisation] public class Book : BaseItem, IHasLookupInfo, IHasSeries { - public Book() - { - this.RunTimeTicks = TimeSpan.TicksPerSecond; - } - [JsonIgnore] public override MediaType MediaType => MediaType.Book; diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 118ccf8679..62827c07b9 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1116,7 +1116,7 @@ namespace MediaBrowser.Providers.Manager { if (replaceData || !target.RunTimeTicks.HasValue) { - if (target is not Audio && target is not Video) + if (target is not Audio && target is not Video && target is not Book) { target.RunTimeTicks = source.RunTimeTicks; } diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index df51dd8421..2b0f480b1c 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -21,6 +21,7 @@ + diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 789df8f061..221c6bff5e 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -24,6 +24,8 @@ using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using PDFtoImage; +using SharpCompress.Archives; namespace MediaBrowser.Providers.MediaInfo { @@ -37,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo ICustomMetadataProvider