From 2fcf4084f8bd58108bdef65c62b5722bb46d38e5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 21 Jun 2026 09:40:06 +0200 Subject: Add TMDb missing episode provider --- .../Item/BaseItemRepository.QueryBuilding.cs | 5 +- .../Item/ItemCountService.cs | 5 +- .../Tmdb/Configuration/PluginConfiguration.cs | 47 ++ .../Plugins/Tmdb/Configuration/config.html | 72 +++ .../Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs | 489 +++++++++++++++++++++ .../Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs | 213 +++++++++ .../DescendantQueryHelper.cs | 9 + .../Tmdb/TmdbMissingEpisodeProviderTests.cs | 193 ++++++++ 8 files changed, 1029 insertions(+), 4 deletions(-) create mode 100644 MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs create mode 100644 MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs create mode 100644 tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index d6ddf8f5c8..1b02f2ae41 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -387,7 +387,8 @@ public sealed partial class BaseItemRepository var baseQuery = context.BaseItems .AsNoTracking() - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); return ApplyAccessFiltering(context, baseQuery, filter); } @@ -507,7 +508,7 @@ public sealed partial class BaseItemRepository var leafItems = context.BaseItems .AsNoTracking() - .Where(b => !b.IsFolder && !b.IsVirtualItem); + .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = ApplyAccessFiltering(context, leafItems, filter); var playedLeafItems = leafItems diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 604db9f839..3c7a96c78c 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -293,7 +293,8 @@ public class ItemCountService : IItemCountService var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId); var baseQuery = dbContext.BaseItems - .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + .Where(b => allDescendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); @@ -354,7 +355,7 @@ public class ItemCountService : IItemCountService var userId = user.Id; var leafItems = dbContext.BaseItems - .Where(b => !b.IsFolder && !b.IsVirtualItem); + .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); var playedLeafItems = leafItems diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 78405c21fc..0ebeebf1e2 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1819 // Properties should not return arrays + using MediaBrowser.Model.Plugins; namespace MediaBrowser.Providers.Plugins.Tmdb @@ -33,6 +35,51 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// public bool ImportSeasonName { get; set; } + /// + /// Gets or sets a value indicating whether unaired (upcoming) episodes should be created as + /// virtual items from the episode list provided by TMDb. These populate the "Upcoming" view. + /// Enabling this will increase scan times. + /// + public bool ImportUnairedEpisodes { get; set; } + + /// + /// Gets or sets a value indicating whether already aired episodes that are not present in the + /// library should be created as virtual items from the episode list provided by TMDb. These + /// surface as missing episodes. Enabling this will increase scan times. + /// + public bool ImportMissingEpisodes { get; set; } + + /// + /// Gets or sets a value indicating whether specials (season 0) should be included when creating + /// virtual unaired or missing episodes. When disabled, specials are never added and any existing + /// virtual specials created by this provider are removed. + /// + public bool ImportSpecials { get; set; } + + /// + /// Gets or sets the ids (the "N" formatted GUIDs from VirtualFolderInfo.ItemId) of the + /// libraries for which the unaired/missing episode provider is disabled. Whether episodes are + /// imported at all, and how, is still controlled by the global toggles above; this list only + /// opts individual libraries out. Libraries not listed here are enabled, so the global toggles + /// apply to every library unless it is explicitly opted out. + /// + public string[] DisabledMissingEpisodeLibraries { get; set; } = []; + + /// + /// Gets or sets how often, in days, the scheduled task re-checks TMDb for newly announced + /// unaired or missing episodes. This is what keeps the "Upcoming" view current for series + /// whose local files have not changed. + /// + public int MissingEpisodeRefreshIntervalDays { get; set; } = 7; + + /// + /// Gets or sets the number of days a virtual episode is retained after it airs before it is + /// pruned (when missing episode import is disabled). This grace period leaves recently aired + /// episodes in place to allow for the delay between an episode airing and its file being added + /// to the library. + /// + public int UpcomingEpisodeGracePeriodDays { get; set; } = 7; + /// /// Gets or sets a value indicating the maximum number of cast members to fetch for an item. /// diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 4048fc1655..b010749936 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -25,6 +25,40 @@ Import season name from metadata fetched for series. +
+ +
Adds virtual entries for episodes listed on TMDb that have not aired yet. This populates the "Upcoming" view. Specials are never added to the "Upcoming" view.
+
+
+ +
Adds virtual entries for episodes listed on TMDb that have already aired but are not present in your library. Missing episodes are only shown when enabled in the user display preferences. Both options increase scan times.
+
+
+ +
When disabled, specials (season 0) are never added as virtual entries and any existing virtual specials are removed.
+
+
+ +
How often the scheduled task re-checks TMDb for newly announced unaired or missing episodes. Keeps the "Upcoming" view current for series whose local files have not changed.
+
+
+ +
When missing episodes are disabled, how many days a recently aired episode is kept in place before its placeholder is removed. This allows for the delay between an episode airing and its file being added to the library.
+
+
+

Libraries

+
Choose which TV libraries the unaired/missing episode options above apply to. The settings above are global; this only controls which libraries they run for. New libraries are enabled by default.
+
+

Cast & Crew Settings

@@ -85,6 +119,29 @@ Dashboard.showLoadingMsg(); var clientConfig, pluginConfig; + var populateMissingEpisodeLibraries = function (disabledLibraries) { + var container = document.querySelector('#missingEpisodeLibraries'); + ApiClient.getVirtualFolders().then(function (folders) { + // Series only live in TV libraries (and mixed-content libraries, which report + // no collection type), so only those are worth listing here. + var tvLibraries = folders.filter(function (folder) { + return !folder.CollectionType || folder.CollectionType === 'tvshows'; + }); + + if (tvLibraries.length === 0) { + container.innerHTML = '
No TV libraries found.
'; + return; + } + + container.innerHTML = tvLibraries.map(function (folder) { + var checked = disabledLibraries.indexOf(folder.ItemId) === -1 ? ' checked' : ''; + return ''; + }).join(''); + }); + } var configureImageScaling = function() { if (clientConfig === undefined || pluginConfig === undefined) { return; @@ -151,9 +208,16 @@ document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries; document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies; document.querySelector('#importSeasonName').checked = config.ImportSeasonName; + document.querySelector('#importUnairedEpisodes').checked = config.ImportUnairedEpisodes; + document.querySelector('#importMissingEpisodes').checked = config.ImportMissingEpisodes; + document.querySelector('#importSpecials').checked = config.ImportSpecials; + document.querySelector('#missingEpisodeRefreshIntervalDays').value = config.MissingEpisodeRefreshIntervalDays; + document.querySelector('#upcomingEpisodeGracePeriodDays').value = config.UpcomingEpisodeGracePeriodDays; document.querySelector('#hideMissingCastMembers').checked = config.HideMissingCastMembers; document.querySelector('#hideMissingCrewMembers').checked = config.HideMissingCrewMembers; + populateMissingEpisodeLibraries(config.DisabledMissingEpisodeLibraries || []); + var maxCastMembers = document.querySelector('#maxCastMembers'); maxCastMembers.value = config.MaxCastMembers; maxCastMembers.dispatchEvent(new Event('change', { @@ -189,6 +253,14 @@ config.ExcludeTagsSeries = document.querySelector('#excludeTagsSeries').checked; config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked; config.ImportSeasonName = document.querySelector('#importSeasonName').checked; + config.ImportUnairedEpisodes = document.querySelector('#importUnairedEpisodes').checked; + config.ImportMissingEpisodes = document.querySelector('#importMissingEpisodes').checked; + config.ImportSpecials = document.querySelector('#importSpecials').checked; + config.MissingEpisodeRefreshIntervalDays = parseInt(document.querySelector('#missingEpisodeRefreshIntervalDays').value, 10); + config.UpcomingEpisodeGracePeriodDays = parseInt(document.querySelector('#upcomingEpisodeGracePeriodDays').value, 10); + config.DisabledMissingEpisodeLibraries = Array.prototype.map.call( + document.querySelectorAll('.missingEpisodeLibrary:not(:checked)'), + function (checkbox) { return checkbox.getAttribute('data-library-id'); }); config.MaxCastMembers = document.querySelector('#maxCastMembers').value; config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value; config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs new file mode 100644 index 0000000000..2b5229a0ab --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs @@ -0,0 +1,489 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using TMDbLib.Objects.Search; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// + /// Creates virtual (metadata-only) entries for missing and unaired episodes. + /// + public class TmdbMissingEpisodeProvider : ICustomMetadataProvider, IHasItemChangeMonitor, IHasOrder + { + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public TmdbMissingEpisodeProvider( + TmdbClientManager tmdbClientManager, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger logger) + { + _tmdbClientManager = tmdbClientManager; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _logger = logger; + } + + /// + public string Name => TmdbUtils.ProviderName; + + /// + // Run after the remote series provider so the TMDb id and other metadata are available. + public int Order => 100; + + /// + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + // Reporting a change makes this provider (and only this provider) run during an otherwise incremental refresh. + if (Plugin.Instance?.Configuration is null) + { + return false; + } + + return item is Series series && series.HasProviderId(MetadataProvider.Tmdb); + } + + /// + public async Task FetchAsync(Series item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var configuration = Plugin.Instance?.Configuration; + var importUnaired = (configuration?.ImportUnairedEpisodes).GetValueOrDefault(); + var importMissing = (configuration?.ImportMissingEpisodes).GetValueOrDefault(); + + // The provider is inactive for this series when both global imports are off, or the series' + // library has been opted out. In either case remove every virtual episode (unaired and missing + // alike) it previously created, so disabling the feature cleans up on the next library scan. + if ((!importUnaired && !importMissing) || !IsEnabledForLibrary(item)) + { + if (!PruneAllVirtualEpisodes(item)) + { + return ItemUpdateType.None; + } + + item.Children = null; + return ItemUpdateType.MetadataImport; + } + + var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); + if (string.IsNullOrEmpty(tmdbId) + || !int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seriesTmdbId) + || seriesTmdbId <= 0) + { + return ItemUpdateType.None; + } + + var language = item.GetPreferredMetadataLanguage(); + var countryCode = item.GetPreferredMetadataCountryCode(); + var imageLanguages = TmdbUtils.GetImageLanguagesParam(language, countryCode); + + var tmdbSeries = await _tmdbClientManager + .GetSeriesAsync(seriesTmdbId, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeries?.Seasons is null) + { + return ItemUpdateType.None; + } + + var today = DateTime.UtcNow.Date; + + var importSpecials = (configuration?.ImportSpecials).GetValueOrDefault(); + var gracePeriodDays = Math.Max(0, (configuration?.UpcomingEpisodeGracePeriodDays).GetValueOrDefault()); + + // Track every (season, episode) number that already exists (physical or virtual) so we never + // create a duplicate. + // When missing episodes are disabled, this pass also prunes virtual episodes that aired more + // than the grace period ago, as well as any specials when specials are not wanted. + var (existingEpisodes, updatableEpisodes) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes); + + var seasonsByNumber = item.GetRecursiveChildren(i => i is Season) + .OfType() + .Where(s => s.IndexNumber.HasValue) + .GroupBy(s => s.IndexNumber!.Value) + .ToDictionary(g => g.Key, g => g.First()); + + var addedEpisodes = false; + var updatedEpisodes = false; + + foreach (var seasonInfo in tmdbSeries.Seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + var seasonNumber = seasonInfo.SeasonNumber; + var tmdbSeason = await _tmdbClientManager + .GetSeasonAsync(seriesTmdbId, seasonNumber, language, imageLanguages, countryCode, cancellationToken) + .ConfigureAwait(false); + + if (tmdbSeason?.Episodes is null) + { + continue; + } + + foreach (var tmdbEpisode in tmdbSeason.Episodes) + { + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + var premiereDate = GetPremiereDate(tmdbEpisode); + + // Skips undated episodes, unaired (upcoming) ones unless upcoming import is enabled, + // already aired ones unless missing import is enabled, and unaired specials entirely. + if (!ShouldImportEpisode(premiereDate, today, importUnaired, importMissing, seasonNumber == 0, importSpecials)) + { + continue; + } + + var key = (seasonNumber, episodeNumber); + + // Already have a virtual episode this provider created, keep metadata in sync with TMDb. + if (updatableEpisodes.TryGetValue(key, out var existingEpisode)) + { + var season = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); + var changed = UpdateVirtualEpisode(existingEpisode, tmdbEpisode, premiereDate); + + if (!existingEpisode.ParentId.Equals(season.Id)) + { + existingEpisode.SetParent(season); + existingEpisode.SeasonId = season.Id; + existingEpisode.SeasonName = season.Name; + changed = true; + } + + if (string.IsNullOrEmpty(existingEpisode.PresentationUniqueKey)) + { + existingEpisode.PresentationUniqueKey = existingEpisode.CreatePresentationUniqueKey(); + changed = true; + } + + if (changed) + { + await existingEpisode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + updatedEpisodes = true; + } + + continue; + } + + if (!existingEpisodes.Add(key)) + { + continue; + } + + var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); + AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); + addedEpisodes = true; + } + } + + if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes) + { + return ItemUpdateType.None; + } + + // Invalidate the cached children so that the season creation / cleanup that runs later in + // SeriesMetadataService.AfterMetadataRefresh observes the newly created (and pruned) episodes. + item.Children = null; + + return ItemUpdateType.MetadataImport; + } + + /// + /// Returns the series' season with the given number, creating (and refreshing) a virtual season + /// when the whole season is missing from the library. + /// + private async Task GetOrCreateSeasonAsync(Series series, int seasonNumber, string? seasonName, Dictionary seasonsByNumber, CancellationToken cancellationToken) + { + if (seasonsByNumber.TryGetValue(seasonNumber, out var existingSeason)) + { + return existingSeason; + } + + _logger.LogInformation("Creating virtual season {SeasonNumber} for series {SeriesName}", seasonNumber, series.Name); + + var season = new Season + { + Name = seasonName, + IndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture), + typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + series.AddChild(season); + await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false); + + seasonsByNumber[seasonNumber] = season; + return season; + } + + private bool IsEnabledForLibrary(BaseItem item) + { + var disabledLibraries = Plugin.Instance?.Configuration.DisabledMissingEpisodeLibraries; + if (disabledLibraries is null || disabledLibraries.Length == 0) + { + return true; + } + + // A series can live under more than one collection folder; treat it as disabled only when + // every containing library is opted out. + var collectionFolders = _libraryManager.GetCollectionFolders(item); + if (collectionFolders.Count == 0) + { + return true; + } + + return collectionFolders.Any(folder => + !disabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); + } + + private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned) + { + var keys = new HashSet<(int Season, int Episode)>(); + var updatable = new Dictionary<(int Season, int Episode), Episode>(); + pruned = false; + + // Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes' + // SeriesPresentationUniqueKey is not set yet, so the presentation-key based query would miss + // them. GetRecursiveChildren walks the actual child tree and sees them regardless. + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType()) + { + // The series is refreshed before its episodes during an initial scan, so a freshly + // resolved physical episode may not have its numbers populated yet. Resolve them from + // the path (in memory, mirroring CreateSeasonsAsync) so we can dedupe against episodes + // the user actually has files for instead of creating virtual duplicates. + if (episode.IsFileProtocol && (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue)) + { + try + { + _libraryManager.FillMissingEpisodeNumbersFromPath(episode, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resolving episode number from path for {Path}", episode.Path); + } + } + + // Virtual episodes this provider created are candidates for metadata sync (and pruning). + var isOurs = episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb); + + if (ShouldPrune(episode, pruneAgedOut, today, gracePeriodDays, importSpecials)) + { + DeleteEpisode(episode, "no longer upcoming and missing episodes are disabled"); + pruned = true; + continue; + } + + if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue) + { + var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value); + keys.Add(key); + + // Virtual episodes this provider created are candidates for metadata sync. + if (isOurs) + { + updatable[key] = episode; + } + } + } + + return (keys, updatable); + } + + /// + /// Removes every virtual episode this provider previously created in the series. + /// + /// The series to clean up. + /// true if any episode was removed; otherwise false. + private bool PruneAllVirtualEpisodes(Series series) + { + var pruned = false; + foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType()) + { + if (episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb)) + { + DeleteEpisode(episode, "the TMDb missing episode provider is disabled for this library"); + pruned = true; + } + } + + return pruned; + } + + private void DeleteEpisode(Episode episode, string reason) + { + _logger.LogInformation( + "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}", + episode.ParentIndexNumber, + episode.IndexNumber, + episode.SeriesName, + reason); + + _libraryManager.DeleteItem( + episode, + new DeleteOptions { DeleteFileLocation = false }, + false); + } + + /// + /// Determines whether a TMDb episode should be imported as a virtual item, based on its air date + /// and the enabled options. Undated episodes are never imported; unaired (today or later) episodes + /// require ; already aired episodes require . + /// Specials (season 0) are only imported when is enabled. + /// + /// The episode air date (UTC), or null if unknown. + /// The current UTC date. + /// Whether unaired (upcoming) episodes should be imported. + /// Whether already aired missing episodes should be imported. + /// Whether the episode belongs to the specials season (season 0). + /// Whether specials should be included. + /// true if the episode should be imported; otherwise false. + internal static bool ShouldImportEpisode(DateTime? premiereDate, DateTime today, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials) + { + if (!premiereDate.HasValue) + { + return false; + } + + // Specials are only imported when the user opts in. + if (isSpecial && !importSpecials) + { + return false; + } + + var isUnaired = premiereDate.Value.Date >= today; + return isUnaired ? importUnaired : importMissing; + } + + /// + /// Determines whether an existing virtual episode created by this provider (carries a TMDb id) + /// should be pruned. Specials are removed entirely unless is + /// enabled. Otherwise, when missing episodes are not wanted, an entry is pruned once its air date + /// is more than in the past; the grace period keeps recently + /// aired episodes in place to allow for the delay between an episode airing and its file being + /// added to the library. + /// + /// The episode to evaluate. + /// Whether aged-out virtual episodes should be pruned (missing import disabled). + /// The current UTC date. + /// The number of days an aired episode is retained before pruning. + /// Whether specials should be kept. + /// true if the episode should be pruned; otherwise false. + internal static bool ShouldPrune(Episode episode, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials) + { + if (!episode.IsVirtualItem || !episode.HasProviderId(MetadataProvider.Tmdb)) + { + return false; + } + + // Specials are removed entirely unless the user opts in. + if (episode.ParentIndexNumber == 0 && !importSpecials) + { + return true; + } + + // When missing episodes are not wanted, prune placeholders for episodes that aired more than + // the grace period ago. + return pruneAgedOut + && episode.PremiereDate.HasValue + && episode.PremiereDate.Value.Date < today.AddDays(-gracePeriodDays); + } + + internal static DateTime? GetPremiereDate(TvSeasonEpisode tmdbEpisode) + { + return tmdbEpisode.AirDate.HasValue + ? DateTime.SpecifyKind(tmdbEpisode.AirDate.Value, DateTimeKind.Local).ToUniversalTime() + : null; + } + + internal static bool UpdateVirtualEpisode(Episode episode, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var changed = false; + + if (!string.IsNullOrEmpty(tmdbEpisode.Name) && !string.Equals(episode.Name, tmdbEpisode.Name, StringComparison.Ordinal)) + { + episode.Name = tmdbEpisode.Name; + changed = true; + } + + if (!string.IsNullOrEmpty(tmdbEpisode.Overview) && !string.Equals(episode.Overview, tmdbEpisode.Overview, StringComparison.Ordinal)) + { + episode.Overview = tmdbEpisode.Overview; + changed = true; + } + + if (premiereDate.HasValue && episode.PremiereDate != premiereDate) + { + episode.PremiereDate = premiereDate; + episode.ProductionYear = tmdbEpisode.AirDate?.Year; + changed = true; + } + + return changed; + } + + private void AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + { + var seasonNumber = season.IndexNumber.GetValueOrDefault(); + var episodeNumber = (int)tmdbEpisode.EpisodeNumber; + + // Leaving Path unset makes the item a virtual (metadata-only) episode. + var episode = new Episode + { + Name = tmdbEpisode.Name, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Id = _libraryManager.GetNewItemId( + series.Id.ToString("N", CultureInfo.InvariantCulture) + + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture) + + "Episode" + episodeNumber.ToString(CultureInfo.InvariantCulture), + typeof(Episode)), + IsVirtualItem = true, + PremiereDate = premiereDate, + ProductionYear = tmdbEpisode.AirDate?.Year, + Overview = tmdbEpisode.Overview, + SeasonId = season.Id, + SeasonName = season.Name, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + episode.PresentationUniqueKey = episode.CreatePresentationUniqueKey(); + + if (tmdbEpisode.Id > 0) + { + episode.SetProviderId(MetadataProvider.Tmdb, tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture)); + } + + _logger.LogInformation( + "Creating virtual episode S{SeasonNumber}E{EpisodeNumber} for series {SeriesName}", + seasonNumber, + episodeNumber, + series.Name); + + season.AddChild(episode); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs new file mode 100644 index 0000000000..0fb7215f27 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV +{ + /// + /// Scheduled task that re-checks TMDb for newly announced unaired and missing episodes and creates + /// the corresponding virtual items. This keeps the "Upcoming" view current for series whose local + /// files have not changed, which an ordinary library scan would never re-examine. + /// + public class TmdbUpcomingEpisodesTask : IScheduledTask + { + private const int DefaultIntervalDays = 7; + + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + public TmdbUpcomingEpisodesTask( + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger logger) + { + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _logger = logger; + } + + /// + public string Name => "Refresh upcoming and missing episodes (TheMovieDb)"; + + /// + public string Description => "Checks TheMovieDb for newly announced episodes and creates virtual entries for unaired and missing episodes, according to the TMDb plugin settings. When both options are disabled, removes any virtual entries previously created."; + + /// + public string Category => "Library"; + + /// + public string Key => "TmdbRefreshUpcomingEpisodes"; + + /// + public IEnumerable GetDefaultTriggers() + { + var intervalDays = Plugin.Instance?.Configuration.MissingEpisodeRefreshIntervalDays ?? DefaultIntervalDays; + if (intervalDays <= 0) + { + intervalDays = DefaultIntervalDays; + } + + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfoType.IntervalTrigger, + IntervalTicks = TimeSpan.FromDays(intervalDays).Ticks + }; + } + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + var configuration = Plugin.Instance?.Configuration; + if (configuration is null) + { + progress.Report(100); + return; + } + + // The feature is fully disabled: remove every virtual episode (and now-empty virtual season) + // this provider previously created, across all libraries, then stop. + if (!configuration.ImportUnairedEpisodes && !configuration.ImportMissingEpisodes) + { + RemoveAllVirtualItems(progress, cancellationToken); + return; + } + + // Process non-ended series (they may have gained episodes) plus any series in a library that + // has been opted out (regardless of status) so the provider can prune the virtual episodes it + // previously created there. Ended series in enabled libraries cannot change, so they're skipped. + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series], + Recursive = true + }) + .OfType() + .Where(s => s.HasProviderId(MetadataProvider.Tmdb) + && (s.Status != SeriesStatus.Ended || !IsEnabledForLibrary(s))) + .ToList(); + + if (series.Count == 0) + { + progress.Report(100); + return; + } + + // ValidateChildren (rather than a bare RefreshMetadata) is required so the created episodes + // are immediately visible. + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.Default, + ImageRefreshMode = MetadataRefreshMode.ValidationOnly, + IsAutomated = true + }; + + for (var i = 0; i < series.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await series[i].ValidateChildren(new Progress(), refreshOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing upcoming episodes for series {SeriesName}", series[i].Name); + } + + progress.Report(100.0 * (i + 1) / series.Count); + } + } + + private bool IsEnabledForLibrary(BaseItem item) + { + var disabledLibraries = Plugin.Instance?.Configuration.DisabledMissingEpisodeLibraries; + if (disabledLibraries is null || disabledLibraries.Length == 0) + { + return true; + } + + // A series can live under more than one collection folder; treat it as disabled only when + // every containing library is opted out. + var collectionFolders = _libraryManager.GetCollectionFolders(item); + if (collectionFolders.Count == 0) + { + return true; + } + + return collectionFolders.Any(folder => + !disabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)); + } + + /// + /// Removes every virtual episode this provider created (identified by being virtual and carrying + /// a TMDb id), plus any virtual season left without episodes as a result. Used when both import + /// options are disabled so turning the feature off cleans up its placeholders. + /// + private void RemoveAllVirtualItems(IProgress progress, CancellationToken cancellationToken) + { + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + + var virtualEpisodes = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Episode], + IsVirtualItem = true, + HasTmdbId = true, + Recursive = true + }); + + for (var i = 0; i < virtualEpisodes.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogInformation("Removing virtual episode {Name}: the TMDb missing episode provider is disabled", virtualEpisodes[i].Name); + _libraryManager.DeleteItem(virtualEpisodes[i], deleteOptions, false); + + progress.Report(95.0 * (i + 1) / virtualEpisodes.Count); + } + + // Remove virtual seasons that are now empty (mirrors the cleanup an ordinary series refresh does). + // Seasons created by this provider carry a TVDB id (from TMDb's external ids), not a TMDb id, + // so they cannot be filtered by HasTmdbId; any virtual season left without episodes is obsolete. + var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season], + IsVirtualItem = true, + Recursive = true + }); + + foreach (var season in virtualSeasons.OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (season.GetEpisodes().Count == 0) + { + _libraryManager.DeleteItem(season, deleteOptions, false); + } + } + + progress.Report(100); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 88a2c684ff..bfd0fac34a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.MatchCriteria; @@ -12,6 +13,14 @@ namespace Jellyfin.Database.Implementations; /// public static class DescendantQueryHelper { + /// + /// Gets the predicate identifying items that count toward played/total aggregation: + /// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes). + /// Shared by the per-item and batched count paths so they cannot diverge. + /// + public static Expression> IsCountableLeaf { get; } = + b => !b.IsFolder && !b.IsVirtualItem; + /// /// Gets a queryable of all descendant IDs for a parent item. /// Traverses AncestorIds and LinkedChildren to find all descendants. diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs new file mode 100644 index 0000000000..f4b7bb5b75 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs @@ -0,0 +1,193 @@ +using System; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb.TV; +using TMDbLib.Objects.Search; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb; + +public class TmdbMissingEpisodeProviderTests +{ + private static readonly DateTime _today = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc); + + [Theory] + // No air date -> never imported, regardless of options. + [InlineData(null, true, true, false, false, false)] + [InlineData(null, false, false, false, false, false)] + // Future (unaired) episodes are gated by the unaired option. + [InlineData(5, true, false, false, false, true)] + [InlineData(5, false, false, false, false, false)] + [InlineData(5, false, true, false, false, false)] + // Today counts as unaired. + [InlineData(0, true, false, false, false, true)] + [InlineData(0, false, false, false, false, false)] + // Past (already aired) episodes are gated by the missing option. + [InlineData(-5, false, true, false, false, true)] + [InlineData(-5, false, false, false, false, false)] + [InlineData(-5, true, false, false, false, false)] + // Specials are never imported when the specials option is off, regardless of air date. + [InlineData(5, true, false, true, false, false)] + [InlineData(-5, false, true, true, false, false)] + // Specials follow the normal air-date gating when the specials option is on. + [InlineData(5, true, false, true, true, true)] + [InlineData(5, false, false, true, true, false)] + [InlineData(-5, false, true, true, true, true)] + [InlineData(-5, false, false, true, true, false)] + public void ShouldImportEpisode_RespectsAirDateAndOptions(int? dayOffset, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials, bool expected) + { + DateTime? premiere = dayOffset.HasValue ? _today.AddDays(dayOffset.Value) : null; + + Assert.Equal(expected, TmdbMissingEpisodeProvider.ShouldImportEpisode(premiere, _today, importUnaired, importMissing, isSpecial, importSpecials)); + } + + [Fact] + public void ShouldPrune_AgedOutVirtualTmdbEpisode_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_NotInPruningMode_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_StillUpcoming_ReturnsFalse() + { + var episode = VirtualEpisode(_today.AddDays(1), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_VirtualEpisodeFromAnotherProvider_ReturnsFalse() + { + // No TMDb id -> not created by this provider (e.g. a TheTVDB plugin entry) -> left untouched. + var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: false); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_PhysicalEpisode_ReturnsFalse() + { + var episode = new Episode { Path = "/media/show/Season 01/s01e01.mkv", PremiereDate = _today.AddDays(-1) }; + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredWithinGracePeriod_ReturnsFalse() + { + // Aired two days ago but the grace period keeps it around for the file to be added. + var episode = VirtualEpisode(_today.AddDays(-2), withTmdbId: true); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_AiredBeyondGracePeriod_ReturnsTrue() + { + var episode = VirtualEpisode(_today.AddDays(-10), withTmdbId: true); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsDisabled_ReturnsTrue() + { + // Specials are removed entirely when the specials option is off, even when not in pruning mode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 7, importSpecials: false)); + } + + [Fact] + public void ShouldPrune_SpecialWithSpecialsEnabled_FollowsNormalRules() + { + // With specials enabled, an upcoming special is kept like any other upcoming episode. + var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0); + + Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true)); + } + + [Fact] + public void GetPremiereDate_NullAirDate_ReturnsNull() + { + Assert.Null(TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = null })); + } + + [Fact] + public void GetPremiereDate_AirDate_ReturnsUtc() + { + var airDate = new DateTime(2026, 7, 28); + + var result = TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = airDate }); + + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result!.Value.Kind); + Assert.Equal(DateTime.SpecifyKind(airDate, DateTimeKind.Local).ToUniversalTime(), result.Value); + } + + [Fact] + public void UpdateVirtualEpisode_PlaceholderTitleReplaced_UpdatesAndReturnsTrue() + { + var episode = new Episode { Name = "Episode 14" }; + var tmdbEpisode = new TvSeasonEpisode { Name = "The Real Title" }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("The Real Title", episode.Name); + } + + [Fact] + public void UpdateVirtualEpisode_NoChanges_ReturnsFalse() + { + var date = _today; + var episode = new Episode { Name = "Same", Overview = "Description", PremiereDate = date }; + var tmdbEpisode = new TvSeasonEpisode { Name = "Same", Overview = "Description" }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, date)); + } + + [Fact] + public void UpdateVirtualEpisode_EmptyTmdbValues_DoNotOverwrite() + { + var episode = new Episode { Name = "Existing", Overview = "Existing overview" }; + var tmdbEpisode = new TvSeasonEpisode { Name = string.Empty, Overview = null }; + + Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null)); + Assert.Equal("Existing", episode.Name); + Assert.Equal("Existing overview", episode.Overview); + } + + [Fact] + public void UpdateVirtualEpisode_RescheduledAirDate_UpdatesPremiereAndYear() + { + var episode = new Episode { Name = "X", PremiereDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) }; + var newAirDate = new DateTime(2026, 8, 15); + var newPremiere = DateTime.SpecifyKind(newAirDate, DateTimeKind.Local).ToUniversalTime(); + var tmdbEpisode = new TvSeasonEpisode { Name = "X", AirDate = newAirDate }; + + Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, newPremiere)); + Assert.Equal(newPremiere, episode.PremiereDate); + Assert.Equal(2026, episode.ProductionYear); + } + + private static Episode VirtualEpisode(DateTime premiereDate, bool withTmdbId, int? seasonNumber = null) + { + var episode = new Episode { PremiereDate = premiereDate, IsVirtualItem = true, ParentIndexNumber = seasonNumber }; + if (withTmdbId) + { + episode.SetProviderId(MetadataProvider.Tmdb, "123"); + } + + return episode; + } +} -- cgit v1.2.3 From c4ace4ac958b9bba8fe90b6c4e3215eb0137b6a2 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 25 Jun 2026 19:29:52 +0200 Subject: Set TMDb id in season provider --- MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 1eb522137d..9c41d64253 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -76,11 +76,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item.Name = seasonResult.Name; } + result.Item.TrySetProviderId(MetadataProvider.Tmdb, seasonResult.Id?.ToString(CultureInfo.InvariantCulture)); result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId); - // TODO why was this disabled? var credits = seasonResult.Credits; - if (credits?.Cast is not null) { var castQuery = config.HideMissingCastMembers -- cgit v1.2.3 From 5221584b06b3b5d9de0a93f757385abd85e7c876 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 25 Jun 2026 20:54:05 +0200 Subject: Only process items with tmdb id --- MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs index 0fb7215f27..bb8c9bdcf2 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs @@ -188,12 +188,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } // Remove virtual seasons that are now empty (mirrors the cleanup an ordinary series refresh does). - // Seasons created by this provider carry a TVDB id (from TMDb's external ids), not a TMDb id, - // so they cannot be filtered by HasTmdbId; any virtual season left without episodes is obsolete. var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Season], IsVirtualItem = true, + HasTmdbId = true, Recursive = true }); -- cgit v1.2.3 From ce43df6f43b851e7b09dd6b91ed52a3335feb7a2 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 10 Jul 2026 14:19:08 -0400 Subject: Fix host and port handling for published server URI overrides --- Emby.Server.Implementations/ApplicationHost.cs | 5 ++- src/Jellyfin.Networking/Manager/NetworkManager.cs | 49 +++++++++++++++------- .../Jellyfin.Networking.Tests/NetworkParseTests.cs | 34 +++++++++++++++ 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 69e23bcb63..1b37e7498d 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -965,8 +965,9 @@ namespace Emby.Server.Implementations /// public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - // If the smartAPI doesn't start with http then treat it as a host or ip. - if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + // If the smartAPI isn't already a complete URL then treat it as a host or ip. + if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return hostname.TrimEnd('/'); } diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 4559f68ce8..69f36e9bb1 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -851,7 +851,7 @@ public class NetworkManager : INetworkManager, IDisposable bool isExternal = !IsInLocalNetwork(source); _logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal); - if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result)) + if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result, out port)) { return result; } @@ -1017,11 +1017,12 @@ public class NetworkManager : INetworkManager, IDisposable /// IP source address to use. /// True if the source is in an external subnet. /// The published server URL that matches the source address. + /// The explicit port parsed from the override, if any. /// true if a match is found, false otherwise. - private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference) + private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference, out int? port) { bindPreference = string.Empty; - int? port = null; + port = null; // Only consider subnets including the source IP, preferring specific overrides List validPublishedServerUrls; @@ -1063,24 +1064,42 @@ public class NetworkManager : INetworkManager, IDisposable return false; } - // Handle override specifying port - var parts = bindPreference.Split(':'); - if (parts.Length > 1) + // Handle override specifying an explicit port. + (bindPreference, port) = ParseHostAndPort(bindPreference); + + if (port.HasValue) { - if (int.TryParse(parts[1], out int p)) - { - bindPreference = parts[0]; - port = p; - _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); - return true; - } + _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); + } + else + { + _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); } - - _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); return true; } + /// + /// Splits a published server URL override into its host and explicit port, if any. + /// Full URLs (containing "://") are returned whole, with any port left embedded. + /// + /// The override value, e.g. "host:port", "[::1]:port", or a full URL. + /// The parsed host (or the original value if not split) and the explicit port, if any. + private static (string Host, int? Port) ParseHostAndPort(string value) + { + if (value.Contains("://", StringComparison.Ordinal)) + { + return (value, null); + } + + if (Uri.TryCreate("any://" + value, UriKind.Absolute, out var parsed) && parsed.Port != -1) + { + return (parsed.DnsSafeHost, parsed.Port); + } + + return (value, null); + } + /// /// Attempts to match the source against the user defined bind interfaces. /// diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 1f523f7f21..3a5b874682 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -493,5 +493,39 @@ namespace Jellyfin.Networking.Tests Assert.Equal(result, interfaceToUse); } + + [Theory] + // Internal override with an explicit port. + [InlineData("192.168.1.1", "192.168.1.0/24=internal.jellyfin:8097", "internal.jellyfin", 8097)] + // External/all override with an explicit port. + [InlineData("8.8.8.8", "all=external.jellyfin:8097", "external.jellyfin", 8097)] + // Bracketed IPv6 override with an explicit port. + [InlineData("8.8.8.8", "all=[fd00:1234::1]:8097", "fd00:1234::1", 8097)] + // Bare IPv6 override without a port - must remain whole, not mangled by the extra colons. + [InlineData("8.8.8.8", "all=fd00:1234::1", "fd00:1234::1", null)] + // Full HTTPS URL override with an explicit port - the URL stays whole, port stays embedded. + [InlineData("8.8.8.8", "all=https://secure.jellyfin.org:8920", "https://secure.jellyfin.org:8920", null)] + // Hostname beginning with "http" is a hostname, not a URL scheme. + [InlineData("8.8.8.8", "all=http-proxy.lan:8097", "http-proxy.lan", 8097)] + public void GetBindAddress_PublishedServerOverride_ParsesHostAndPort(string source, string publishedServers, string expectedHost, int? expectedPort) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger()); + NetworkManager.MockNetworkSettings = string.Empty; + + var intf = nm.GetBindAddress(IPAddress.Parse(source), out int? port); + + Assert.Equal(expectedHost, intf); + Assert.Equal(expectedPort, port); + } } } -- cgit v1.2.3 From 6bbd6dcd447223b0b12f37bd7e40a306ffcb0947 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 10 Jul 2026 20:26:29 -0400 Subject: Resolve Live TV client stream URLs per request --- Jellyfin.Api/Controllers/MediaInfoController.cs | 3 +- .../Controllers/UniversalAudioController.cs | 1 + Jellyfin.Api/Helpers/MediaInfoHelper.cs | 93 +++++++- .../Helpers/MediaInfoHelperTests.cs | 252 ++++++++++++++++++++- 4 files changed, 343 insertions(+), 6 deletions(-) diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index ac7c091f85..aa942e7642 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -84,7 +84,7 @@ public class MediaInfoController : BaseJellyfinApiController return NotFound(); } - return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false); + return await _mediaInfoHelper.GetPlaybackInfo(item, user, Request).ConfigureAwait(false); } /// @@ -177,6 +177,7 @@ public class MediaInfoController : BaseJellyfinApiController var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId, liveStreamId) .ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index e53d15acfd..cdbd1ee7aa 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -133,6 +133,7 @@ public class UniversalAudioController : BaseJellyfinApiController var info = await _mediaInfoHelper.GetPlaybackInfo( item, user, + Request, mediaSourceId) .ConfigureAwait(false); diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index ef81235808..f178a79999 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Entities; @@ -44,6 +45,7 @@ public class MediaInfoHelper private readonly ILogger _logger; private readonly INetworkManager _networkManager; private readonly IDeviceManager _deviceManager; + private readonly IServerApplicationHost _appHost; /// /// Initializes a new instance of the class. @@ -56,6 +58,7 @@ public class MediaInfoHelper /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public MediaInfoHelper( IUserManager userManager, ILibraryManager libraryManager, @@ -64,7 +67,8 @@ public class MediaInfoHelper IServerConfigurationManager serverConfigurationManager, ILogger logger, INetworkManager networkManager, - IDeviceManager deviceManager) + IDeviceManager deviceManager, + IServerApplicationHost appHost) { _userManager = userManager; _libraryManager = libraryManager; @@ -74,6 +78,7 @@ public class MediaInfoHelper _logger = logger; _networkManager = networkManager; _deviceManager = deviceManager; + _appHost = appHost; } /// @@ -81,12 +86,14 @@ public class MediaInfoHelper /// /// The item. /// The user. + /// The current . /// Media source id. /// Live stream id. /// A containing the . public async Task GetPlaybackInfo( BaseItem item, User? user, + HttpRequest request, string? mediaSourceId = null, string? liveStreamId = null) { @@ -136,6 +143,11 @@ public class MediaInfoHelper mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource; } + foreach (var mediaSource in mediaSourcesClone) + { + RewritePublishedLiveStreamPath(mediaSource, request); + } + result.MediaSources = mediaSourcesClone; } @@ -415,6 +427,8 @@ public class MediaInfoHelper { var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request); + var profile = request.DeviceProfile; if (profile is null) { @@ -524,4 +538,81 @@ public class MediaInfoHelper return maxBitrate; } + + /// + /// Rewrites a Live TV media source's to the request-appropriate published + /// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local + /// addresses. Only opened live streams are eligible. The shared instance held by + /// is never touched by this method. + /// + /// The media source clone to rewrite in place. + /// The current . + private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request) + { + // Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources. + if (string.IsNullOrEmpty(mediaSource.LiveStreamId)) + { + return; + } + + if (mediaSource.Protocol != MediaProtocol.Http) + { + return; + } + + var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl; + var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl); + + if (publishedPath is not null) + { + mediaSource.Path = publishedPath; + return; + } + + if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id); + } + } + + /// + /// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent. + /// Returns null when the path isn't a Jellyfin-hosted /LiveTv/LiveStreamFiles/ HTTP URL. + /// + /// The request-appropriate base URL, as returned by . + /// The media source's local (LAN-access) path, as built from . + /// The media source's protocol. + /// The server's configured BaseUrl, if any. + /// The published path, or null if the local path should be left unchanged. + internal static string? GetPublishedLiveStreamPath( + string smartApiUrl, + string? localPath, + MediaProtocol protocol, + string baseUrl) + { + if (protocol != MediaProtocol.Http + || !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri)) + { + return null; + } + + var relativePath = localUri.PathAndQuery; + if (!string.IsNullOrEmpty(baseUrl)) + { + var basePrefix = baseUrl + "/"; + if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + relativePath = relativePath[baseUrl.Length..]; + } + + if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return smartApiUrl.TrimEnd('/') + relativePath; + } } diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs index a003be4d96..6dd2f27af7 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -1,13 +1,21 @@ using System; using System.Globalization; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Api.Helpers; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common.Net; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.MediaInfo; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; using Xunit; @@ -16,17 +24,28 @@ namespace Jellyfin.Api.Tests.Helpers { public class MediaInfoHelperTests { - private static MediaInfoHelper CreateHelper() + private const string LiveStreamFilesPath = "/LiveTv/LiveStreamFiles/abc/stream.ts"; + + private static MediaInfoHelper CreateHelper( + IMediaSourceManager? mediaSourceManager = null, + IServerApplicationHost? appHost = null, + string baseUrl = "") { + var serverConfigurationManager = new Mock(); + serverConfigurationManager + .Setup(x => x.GetConfiguration(It.IsAny())) + .Returns(new NetworkConfiguration { BaseUrl = baseUrl }); + return new MediaInfoHelper( Mock.Of(), Mock.Of(), - Mock.Of(), + mediaSourceManager ?? Mock.Of(), Mock.Of(), - Mock.Of(), + serverConfigurationManager.Object, Mock.Of>(), Mock.Of(), - Mock.Of()); + Mock.Of(), + appHost ?? Mock.Of()); } private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true) @@ -95,5 +114,230 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(directPlay.Id, result.MediaSources[0].Id); } + + [Fact] + public async Task GetPlaybackInfo_ExistingLiveStream_RewritesReturnedCloneOnly() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny(), It.IsAny())) + .ReturnsAsync(sharedLiveSource); + + var appHost = new Mock(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var result = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of(), liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, result.MediaSources[0].Path); + + // The shared instance handed back by GetLiveStream must remain untouched; only the clone in the response may be rewritten. + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task OpenMediaSource_RewritesReturnedLiveStreamPath() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://127.0.0.1:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://public.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://public.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ExternalDockerBridgeBehindReverseProxy_UsesPublishedUrl() + { + const string LocalPath = "http://172.23.0.5:8096" + LiveStreamFilesPath; + + // Represents the instance MediaSourceManager keeps for its own bookkeeping; the helper never sees it + // and must not be able to affect it. + var localSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + // Mirrors production: MediaSourceManager.OpenLiveStream hands back its own instance, so what the + // helper mutates must be a deserialized copy, never localSource itself. + var clone = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(localSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny())).Returns("https://jellyfin.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://jellyfin.example.com" + LiveStreamFilesPath, response.MediaSource.Path); + + // The mock now actually derives its response from localSource, so this assertion is meaningful: + // rewriting the returned clone must never mutate the object localSource represents. + Assert.Equal(LocalPath, localSource.Path); + } + + [Fact] + public async Task OpenMediaSource_ForeignHostWithLiveStreamFilesRoute_PathUnchanged() + { + // A plugin or remote source can expose a path that happens to match the /LiveTv/LiveStreamFiles/ + // route shape without actually being hosted by this server. Only opened streams (which always + // carry a LiveStreamId) are eligible for rewriting. + const string ForeignPath = "https://other-server:8096" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = ForeignPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(ForeignPath, response.MediaSource.Path); + } + + [Theory] + [InlineData(MediaProtocol.Http, "http://192.168.1.50:5004/live/channel1.ts")] + [InlineData(MediaProtocol.File, "/media/livetv/buffer/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/Videos/abc/stream.ts")] + public async Task OpenMediaSource_NotAPublishableLiveStreamFilesPath_PathUnchanged(MediaProtocol protocol, string path) + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = protocol, + Path = path + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(path, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlConfigured_RewritesWithBaseUrlPrefix() + { + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("https://media.example.com/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task OpenMediaSource_BaseUrlSegmentMismatch_PathUnchanged() + { + const string LocalPath = "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath; + + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal(LocalPath, response.MediaSource.Path); + } + + [Theory] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "?token=1", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath + "?token=1")] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096" + LiveStreamFilesPath + "#fragment", + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + null)] + [InlineData( + "https://media.example.com", + "/media/livetv/buffer/abc/stream.ts", + MediaProtocol.File, + "", + null)] + [InlineData( + "https://media.example.com", + "not a uri", + MediaProtocol.Http, + "", + null)] + public void GetPublishedLiveStreamPath_VariousInputs_ReturnsExpected(string smartApiUrl, string localPath, MediaProtocol protocol, string baseUrl, string? expected) + { + var result = MediaInfoHelper.GetPublishedLiveStreamPath(smartApiUrl, localPath, protocol, baseUrl); + + Assert.Equal(expected, result); + } + + private static MediaInfoHelper CreateOpenMediaSourceHelper(MediaSourceInfo mediaSource, string smartApiUrl, string baseUrl = "") + { + var mediaSourceManager = new Mock(); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LiveStreamResponse(mediaSource)); + + var appHost = new Mock(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny())).Returns(smartApiUrl); + + return CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object, baseUrl: baseUrl); + } } } -- cgit v1.2.3 From a3ec1a3712f162c3704913b64026585316fbdd31 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 10 Jul 2026 20:27:10 -0400 Subject: Add Live TV published URL regression coverage --- .../Helpers/MediaInfoHelperTests.cs | 131 +++++++++++++++++++++ .../Jellyfin.Networking.Tests/NetworkParseTests.cs | 92 +++++++++++++++ 2 files changed, 223 insertions(+) diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs index 6dd2f27af7..d93935a9d0 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -230,6 +230,7 @@ namespace Jellyfin.Api.Tests.Helpers [InlineData(MediaProtocol.Http, "http://192.168.1.50:5004/live/channel1.ts")] [InlineData(MediaProtocol.File, "/media/livetv/buffer/abc/stream.ts")] [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/Videos/abc/stream.ts")] + [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/library/movie.strm")] public async Task OpenMediaSource_NotAPublishableLiveStreamFilesPath_PathUnchanged(MediaProtocol protocol, string path) { var mediaSource = new MediaSourceInfo @@ -283,6 +284,136 @@ namespace Jellyfin.Api.Tests.Helpers Assert.Equal(LocalPath, response.MediaSource.Path); } + [Fact] + public async Task OpenMediaSource_ExplicitPortOverrideWithBaseUrl_RewritesToOverrideHostAndPort() + { + // Mirrors NetworkManager.GetBindAddress resolving a "internal=myhost:8097" override: the smart API + // URL carries an explicit non-default port alongside the configured BaseUrl. + var mediaSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var helper = CreateOpenMediaSourceHelper(mediaSource, "http://myhost:8097/jellyfin", "/jellyfin"); + + var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true); + + Assert.Equal("http://myhost:8097/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_TwoRequestsForSharedLiveStream_ReceiveIndependentSmartApiBases() + { + const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath; + + // Both requests resolve the same live stream; the manager hands back its own shared instance each time. + var sharedLiveSource = new MediaSourceInfo + { + Id = "abc", + Protocol = MediaProtocol.Http, + Path = LocalPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock(); + mediaSourceManager + .Setup(x => x.GetLiveStream(It.IsAny(), It.IsAny())) + .ReturnsAsync(sharedLiveSource); + + var requestA = new DefaultHttpContext().Request; + var requestB = new DefaultHttpContext().Request; + + var appHost = new Mock(); + appHost.Setup(x => x.GetSmartApiUrl(requestA)).Returns("https://a.example.com"); + appHost.Setup(x => x.GetSmartApiUrl(requestB)).Returns("https://b.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var resultA = await helper.GetPlaybackInfo(new Movie(), null, requestA, liveStreamId: "live-1").ConfigureAwait(true); + var resultB = await helper.GetPlaybackInfo(new Movie(), null, requestB, liveStreamId: "live-1").ConfigureAwait(true); + + Assert.Equal("https://a.example.com" + LiveStreamFilesPath, resultA.MediaSources[0].Path); + Assert.Equal("https://b.example.com" + LiveStreamFilesPath, resultB.MediaSources[0].Path); + + // Neither request's rewrite may leak into the other's response or into the shared instance. + Assert.NotEqual(resultA.MediaSources[0].Path, resultB.MediaSources[0].Path); + Assert.Equal(LocalPath, sharedLiveSource.Path); + } + + [Fact] + public async Task GetPlaybackInfo_AutoOpenLiveStreamFlow_MergedOpenedSourceHasRewrittenPath() + { + // Reproduces MediaInfoController.GetPostedPlaybackInfo's AutoOpenLiveStream branch (~line 220-246): + // it picks the RequiresOpening source out of GetPlaybackInfo's result, calls OpenMediaSource, then + // merges by replacing result.MediaSources with the opened source. Building a full controller fixture + // is impractical (it pulls in many unrelated dependencies), so this test drives the same two helper + // calls the controller makes and asserts the merged source is the rewritten one. + var itemId = Guid.NewGuid(); + var sourceId = itemId.ToString("N", CultureInfo.InvariantCulture); + + // The pre-open placeholder source carries a different local path than the one OpenMediaSource + // eventually returns, so the final assertion can prove the merge picked up the freshly opened + // source rather than the stale placeholder. + var requiresOpeningSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096/LiveTv/LiveStreamFiles/placeholder/stream.ts", + RequiresOpening = true, + LiveStreamId = string.Empty + }; + + var openedSource = new MediaSourceInfo + { + Id = sourceId, + Protocol = MediaProtocol.Http, + Path = "http://172.19.0.3:8096" + LiveStreamFilesPath, + LiveStreamId = "livestream-1" + }; + + var mediaSourceManager = new Mock(); + mediaSourceManager + .Setup(x => x.GetPlaybackMediaSources(It.IsAny(), It.IsAny(), true, true, It.IsAny())) + .ReturnsAsync(new[] { requiresOpeningSource }); + mediaSourceManager + .Setup(x => x.OpenLiveStream(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + // MediaSourceManager.OpenLiveStream JSON-clones its internal MediaSourceInfo before returning + // it (see Emby.Server.Implementations/Library/MediaSourceManager.cs:693-706); mirror that so + // the in-place rewrite below can't be observed on openedSource itself. + var clone = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(openedSource))!; + return new LiveStreamResponse(clone); + }); + + var appHost = new Mock(); + appHost.Setup(x => x.GetSmartApiUrl(It.IsAny())).Returns("https://media.example.com"); + + var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object); + + var info = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of()).ConfigureAwait(true); + + var mediaSource = info.MediaSources[0]; + Assert.True(mediaSource.RequiresOpening); + var preOpenPath = mediaSource.Path; + + var openStreamResult = await helper.OpenMediaSource( + new DefaultHttpContext(), + new LiveStreamRequest { OpenToken = mediaSource.OpenToken, ItemId = itemId }).ConfigureAwait(true); + + // MediaInfoController.cs:245 - info.MediaSources = new[] { openStreamResult.MediaSource }; + info.MediaSources = new[] { openStreamResult.MediaSource }; + + Assert.Equal("https://media.example.com" + LiveStreamFilesPath, info.MediaSources[0].Path); + Assert.NotEqual(preOpenPath, info.MediaSources[0].Path); + + // The pristine OpenLiveStream response object must remain unrewritten; only the merged clone changed. + Assert.Equal("http://172.19.0.3:8096" + LiveStreamFilesPath, openedSource.Path); + } + [Theory] [InlineData( "https://media.example.com", diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 3a5b874682..5f7a0efe8a 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -6,6 +6,7 @@ using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -507,6 +508,10 @@ namespace Jellyfin.Networking.Tests [InlineData("8.8.8.8", "all=https://secure.jellyfin.org:8920", "https://secure.jellyfin.org:8920", null)] // Hostname beginning with "http" is a hostname, not a URL scheme. [InlineData("8.8.8.8", "all=http-proxy.lan:8097", "http-proxy.lan", 8097)] + // Literal "internal" keyword override (applies to every LAN subnet) with an explicit port. + [InlineData("192.168.1.1", "internal=myhost.internal:8097", "myhost.internal", 8097)] + // Literal "external" keyword override with an explicit port. + [InlineData("8.8.8.8", "external=myhost.external:9090", "myhost.external", 9090)] public void GetBindAddress_PublishedServerOverride_ParsesHostAndPort(string source, string publishedServers, string expectedHost, int? expectedPort) { var conf = new NetworkConfiguration @@ -527,5 +532,92 @@ namespace Jellyfin.Networking.Tests Assert.Equal(expectedHost, intf); Assert.Equal(expectedPort, port); } + + /// + /// Regression coverage for IServerApplicationHost.GetApiUrlForLocalAccess(), which calls + /// with a null source address. + /// Published server URL overrides are only matched when a source address is supplied + /// (MatchesPublishedServerUrl requires it), so a null source must never come back as a published + /// CLI/dashboard URL - it must fall back to a plain local bind address. + /// + [Fact] + public void GetBindAddress_NullSource_DoesNotApplyPublishedServerOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "all=http://published.example.com" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger()); + NetworkManager.MockNetworkSettings = string.Empty; + + var result = nm.GetBindAddress((IPAddress?)null, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Null(port); + } + + /// + /// is the piece of request-host + /// normalization that a request-host-aware smart API URL policy relies on: it resolves the bind address + /// from the request's host and falls back to the request's own port when no override applies. + /// + [Fact] + public void GetBindAddress_HttpRequestOverload_FallsBackToRequestPortWhenNoOverride() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("192.168.1.208", result); + Assert.Equal(34567, port); + } + + /// + /// Ordering check: a dashboard published-server-URL override's explicit port takes precedence over the + /// request's own port, even though the request's host chose which override subnet matched. + /// + [Fact] + public void GetBindAddress_HttpRequestOverload_PublishedOverridePortWinsOverRequestPort() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16", "eth11" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { "internal=myhost.internal:9000" } + }; + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; + var startupConf = new Mock(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger()); + NetworkManager.MockNetworkSettings = string.Empty; + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Host = new HostString("192.168.1.1", 34567); + + var result = nm.GetBindAddress(httpContext.Request, out var port); + + Assert.Equal("myhost.internal", result); + Assert.Equal(9000, port); + } } } -- cgit v1.2.3 From f3ff7a446b613c0cd7b83e36a7f2e385f5301e15 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 10 Jul 2026 20:31:14 -0400 Subject: Add WizardOfYendor1 to contributors --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4e323e332a..8ddc925c29 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -172,6 +172,7 @@ - [whooo](https://github.com/whooo) - [WiiPlayer2](https://github.com/WiiPlayer2) - [WillWill56](https://github.com/WillWill56) + - [WizardOfYendor1](https://github.com/WizardOfYendor1) - [wtayl0r](https://github.com/wtayl0r) - [Wuerfelbecher](https://github.com/Wuerfelbecher) - [Wunax](https://github.com/Wunax) -- cgit v1.2.3 From 6f189bf2b81c7ddc80b8f9ad7740df752903cd14 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Sat, 11 Jul 2026 10:12:42 -0400 Subject: Reduce GetPlaybackInfo cognitive complexity --- Jellyfin.Api/Helpers/MediaInfoHelper.cs | 46 ++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index f178a79999..31e907549c 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -99,29 +99,7 @@ public class MediaInfoHelper { var result = new PlaybackInfoResponse(); - MediaSourceInfo[] mediaSources; - if (string.IsNullOrWhiteSpace(liveStreamId)) - { - // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? - var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(mediaSourceId)) - { - mediaSources = mediaSourcesList.ToArray(); - } - else - { - mediaSources = mediaSourcesList - .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - else - { - var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - - mediaSources = new[] { mediaSource }; - } + var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(false); if (mediaSources.Length == 0) { @@ -157,6 +135,28 @@ public class MediaInfoHelper return result; } + private async Task ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, string? liveStreamId) + { + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + + return new[] { mediaSource }; + } + + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(mediaSourceId)) + { + return mediaSourcesList.ToArray(); + } + + return mediaSourcesList + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + /// /// SetDeviceSpecificData. /// -- cgit v1.2.3 From 97e666c56639c5b1f846e684dee391c3b62c0ac9 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 17 Jul 2026 10:50:53 -0400 Subject: Append base URL if the published server URL override omits it. Fleshed out unit tests to cover that and https->http reverse proxy scenario(s). --- Jellyfin.Api/Helpers/MediaInfoHelper.cs | 21 +++++++---- .../Helpers/MediaInfoHelperTests.cs | 42 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 31e907549c..c27a18831c 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -555,11 +555,6 @@ public class MediaInfoHelper return; } - if (mediaSource.Protocol != MediaProtocol.Http) - { - return; - } - var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl; var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl); @@ -613,6 +608,20 @@ public class MediaInfoHelper return null; } - return smartApiUrl.TrimEnd('/') + relativePath; + var prefix = smartApiUrl.TrimEnd('/'); + if (!string.IsNullOrEmpty(baseUrl)) + { + var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri) + && Uri.UnescapeDataString(publishedUri.AbsolutePath) + .TrimEnd('/') + .EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase); + + if (!includesBaseUrl) + { + prefix += baseUrl; + } + } + + return prefix + relativePath; } } diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs index d93935a9d0..fe824eddd9 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -433,6 +433,48 @@ namespace Jellyfin.Api.Tests.Helpers MediaProtocol.Http, "", "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "https://172.19.0.3:8920" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://192.168.1.10:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com:8920", + "http://172.19.0.3:8096" + LiveStreamFilesPath, + MediaProtocol.Http, + "", + "https://media.example.com:8920" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://jellyfin/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] + [InlineData( + "https://media.example.com/jellyfin/", + "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath, + MediaProtocol.Http, + "/jellyfin", + "https://media.example.com/jellyfin" + LiveStreamFilesPath)] [InlineData( "https://media.example.com", "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath, -- cgit v1.2.3 From 0c7428f13675d1b63234cdc3ef5c748eb998e8e9 Mon Sep 17 00:00:00 2001 From: WizardOfYendor1 Date: Fri, 17 Jul 2026 12:02:09 -0400 Subject: Added verbose, rambling, log warning to help users with config issues (hoping to reduce false issues reports). Also added a test to exercise it, which is perhaps silly but convenient. --- src/Jellyfin.Networking/Manager/NetworkManager.cs | 44 +++++++++++ .../Jellyfin.Networking.Tests/NetworkParseTests.cs | 89 ++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 69f36e9bb1..496c108cfd 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -491,6 +491,7 @@ public class NetworkManager : INetworkManager, IDisposable startupOverrideKey, true, true)); + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; return; } @@ -580,10 +581,53 @@ public class NetworkManager : INetworkManager, IDisposable } } + WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl); _publishedServerUrls = publishedServerUrls; } } + /// + /// Warns when a full-URL published server override uses a public path that differs from the configured base + /// URL. Jellyfin appends the base URL to generated Live TV client URLs in this case, which can conflict with + /// reverse proxies that translate public request paths. Bare host/IP overrides are exempt because the base URL + /// is appended when the API URL is built from them. + /// + /// The parsed published server URL overrides. + /// The configured base URL, if any. + private void WarnIfPublishedUrlBasePathDiffers(List publishedServerUrls, string baseUrl) + { + if (string.IsNullOrEmpty(baseUrl)) + { + return; + } + + foreach (var overrideUri in publishedServerUrls.Select(x => x.OverrideUri).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!overrideUri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !overrideUri.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!Uri.TryCreate(overrideUri, UriKind.Absolute, out var uri)) + { + continue; + } + + var path = Uri.UnescapeDataString(uri.AbsolutePath).TrimEnd('/'); + if (path.EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var publishedServerHost = uri.GetComponents(UriComponents.HostAndPort, UriFormat.Unescaped); + _logger.LogWarning( + "The published server URL for host '{PublishedServerHost}' does not end with the configured base URL '{BaseUrl}'. Jellyfin will append this base URL when generating Live TV client URLs. If your reverse proxy translates public paths, this may cause Live TV playback to fail. Update the Published Server URIs setting on the Networking page of the admin dashboard, the JELLYFIN_PublishedServerUrl environment variable / --published-server-url option, or the reverse proxy path mapping accordingly.", + publishedServerHost, + baseUrl); + } + } + private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) { if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 5f7a0efe8a..d8cb9e1ac6 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -562,6 +562,95 @@ namespace Jellyfin.Networking.Tests Assert.Null(port); } + [Theory] + // Full-URL override with a different public path: warn about the Live TV fallback. + [InlineData("all=https://media.example.com", "/jellyfin", true)] + // Full-URL override that ends with the base URL (with and without a trailing slash): no warning. + [InlineData("all=https://media.example.com/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/jellyfin/", "/jellyfin", false)] + [InlineData("all=https://media.example.com/media/jellyfin", "/jellyfin", false)] + [InlineData("all=https://media.example.com/cool%20server", "/cool server", false)] + // A similar segment or a path following the base URL is a different public API base. + [InlineData("all=https://media.example.com/jellyfinx", "/jellyfin", true)] + [InlineData("all=https://media.example.com/jellyfin/media", "/jellyfin", true)] + // No base URL configured: there is no path to compare. + [InlineData("all=https://media.example.com", "", false)] + // Bare host overrides get the base URL appended when the API URL is built: no warning. + [InlineData("all=media.example.com", "/jellyfin", false)] + [InlineData("internal=http-proxy.lan:8097", "/jellyfin", false)] + // Keyword overrides go through the same check as "all". + [InlineData("internal=http://10.0.0.5:8096", "/jellyfin", true)] + public void InitializeOverrides_FullUrlPublicPathDiffersFromBaseUrl_LogsWarning(string publishedServers, string baseUrl, bool expectWarning) + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + PublishedServerUriBySubnet = new[] { publishedServers }, + BaseUrl = baseUrl + }; + + var logger = new Mock>(); + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + var startupConf = new Mock(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, expectWarning ? Times.AtLeastOnce() : Times.Never()); + } + + /// + /// The JELLYFIN_PublishedServerUrl environment variable / --published-server-url option takes the + /// startup-configuration branch of InitializeOverrides and must funnel through the same + /// base URL check as the dashboard overrides. + /// + [Fact] + public void InitializeOverrides_StartupPublishedServerUrlPathDiffersFromBaseUrl_LogsWarningWithoutCredentials() + { + var conf = new NetworkConfiguration + { + LocalNetworkSubnets = new[] { "192.168.1.0/24" }, + LocalNetworkAddresses = new[] { "eth16" }, + EnableIPv4 = true, + BaseUrl = "/jellyfin" + }; + + var logger = new Mock>(); + var startupConf = new Mock(); + startupConf.Setup(x => x[MediaBrowser.Controller.Extensions.ConfigurationExtensions.AddressOverrideKey]).Returns("https://user:password@media.example.com?access_token=secret#fragment"); + + NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16"; + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object); + NetworkManager.MockNetworkSettings = string.Empty; + + VerifyBaseUrlWarning(logger, Times.AtLeastOnce()); + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => state.ToString()!.Contains("user", StringComparison.Ordinal) + || state.ToString()!.Contains("password", StringComparison.Ordinal) + || state.ToString()!.Contains("access_token", StringComparison.Ordinal) + || state.ToString()!.Contains("secret", StringComparison.Ordinal) + || state.ToString()!.Contains("fragment", StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + Times.Never()); + } + + private static void VerifyBaseUrlWarning(Mock> logger, Times times) + { + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => state.ToString()!.Contains("Jellyfin will append this base URL when generating Live TV client URLs", StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + times); + } + /// /// is the piece of request-host /// normalization that a request-host-aware smart API URL policy relies on: it resolves the bind address -- cgit v1.2.3 From 5a2809e33725631ed25c0361331060e1821b66de Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 24 Jul 2026 21:44:46 +0200 Subject: Fix TmdbMissingEpisodeProvider --- .../Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs | 190 ++++++++++++++++++++- .../Plugins/Tmdb/TmdbClientManager.cs | 10 ++ .../Tmdb/TmdbMissingEpisodeProviderTests.cs | 82 +++++++++ 3 files changed, 277 insertions(+), 5 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs index 2b5229a0ab..a0a5e8fdf8 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs @@ -23,6 +23,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV private readonly TmdbClientManager _tmdbClientManager; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; private readonly ILogger _logger; /// @@ -31,16 +32,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// The . /// The . /// The . + /// The . /// The . public TmdbMissingEpisodeProvider( TmdbClientManager tmdbClientManager, ILibraryManager libraryManager, IFileSystem fileSystem, + IProviderManager providerManager, ILogger logger) { _tmdbClientManager = tmdbClientManager; _libraryManager = libraryManager; _fileSystem = fileSystem; + _providerManager = providerManager; _logger = logger; } @@ -179,6 +183,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV updatedEpisodes = true; } + // Backfill the still for placeholders created before images were fetched. + if (await EnsureEpisodeImageAsync(existingEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false)) + { + updatedEpisodes = true; + } + continue; } @@ -188,12 +198,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false); - AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); + var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate); + await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false); addedEpisodes = true; } } - if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes) + var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).ConfigureAwait(false); + + if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons) { return ItemUpdateType.None; } @@ -238,6 +251,105 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return season; } + /// + /// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by + /// number instead of jumping ahead. See for the details. + /// + /// The series' seasons (physical and virtual). + /// The cancellation token. + /// true if any virtual season was updated; otherwise false. + private async Task AlignVirtualSeasonSortNamesAsync(IEnumerable seasons, CancellationToken cancellationToken) + { + var seasonList = seasons.ToList(); + var template = BuildSeasonSortNameTemplate(seasonList); + if (template is null) + { + // No physical season sorts by name: virtual seasons already share the bare-index key space. + return false; + } + + var updated = false; + foreach (var season in seasonList) + { + if (!season.IsVirtualItem || !season.IndexNumber.HasValue) + { + continue; + } + + var desired = template(season.IndexNumber.Value); + if (string.Equals(season.ForcedSortName, desired, StringComparison.Ordinal)) + { + continue; + } + + _logger.LogInformation( + "Aligning sort name of virtual season {SeasonNumber} in series {SeriesName} to {SortName}", + season.IndexNumber, + season.SeriesName, + desired); + + season.ForcedSortName = desired; + await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + updated = true; + } + + return updated; + } + + /// + /// Builds a factory that maps a season number to a forced sort name mirroring a physical, + /// name-sorted sibling season, or null when no physical season sorts by name. + /// + /// The series' seasons (physical and virtual). + /// A season-number-to-sort-name factory, or null if there is nothing to mirror. + internal static Func? BuildSeasonSortNameTemplate(IEnumerable seasons) + { + // Season.CreateSortName sorts by the bare padded index ("0003"), but season NFOs give physical + // seasons a name-based forced sort ("Season 01" -> "season 0000000001"). The digit-leading key + // sorts ahead of the letter-leading one, so mirror the sibling's token with each season number. + var reference = seasons.FirstOrDefault(s => + !s.IsVirtualItem && s.IndexNumber.HasValue && !string.IsNullOrEmpty(s.ForcedSortName)); + if (reference is null) + { + return null; + } + + var forced = reference.ForcedSortName!; + + // Locate the last run of digits (the season number) in the sibling's forced sort name. + var end = -1; + var start = -1; + for (var i = forced.Length - 1; i >= 0; i--) + { + if (char.IsDigit(forced[i])) + { + end = end < 0 ? i : end; + start = i; + } + else if (end >= 0) + { + break; + } + } + + if (end < 0) + { + // Sibling has no numeric component to swap; leave virtual seasons on the bare-index key. + return null; + } + + var prefix = forced[..start]; + var suffix = forced[(end + 1)..]; + var width = end - start + 1; + + // The exact zero-padding is cosmetic: ModifySortChunks pads every digit run to 10 characters, + // so "Season 3" and "Season 03" collapse to the same sort key. Keeping the sibling's width just + // makes the stored value read naturally. + return number => prefix + + number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0') + + suffix; + } + private bool IsEnabledForLibrary(BaseItem item) { var disabledLibraries = Plugin.Instance?.Configuration.DisabledMissingEpisodeLibraries; @@ -262,6 +374,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { var keys = new HashSet<(int Season, int Episode)>(); var updatable = new Dictionary<(int Season, int Episode), Episode>(); + var physicalKeys = new HashSet<(int Season, int Episode)>(); + var ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>(); pruned = false; // Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes' @@ -300,14 +414,37 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value); keys.Add(key); - // Virtual episodes this provider created are candidates for metadata sync. + // Defer the ours/physical reconciliation: an episode's virtual counterpart and its + // physical file can appear in either order while walking the tree, so we can only + // decide which of our virtual episodes are superseded once every episode is seen. if (isOurs) { - updatable[key] = episode; + ourVirtuals.Add((key, episode)); + } + else if (!episode.IsVirtualItem) + { + physicalKeys.Add(key); } } } + // A physical file now exists for one of our placeholders: delete the placeholder here rather + // than updating it (and then leaving RemoveObsoleteEpisodes to delete it moments later). The + // physical key already blocks re-creation via the dedupe set above. + foreach (var (key, episode) in ourVirtuals) + { + if (physicalKeys.Contains(key)) + { + DeleteEpisode(episode, "a physical episode now exists for this slot"); + pruned = true; + } + else + { + // Virtual episodes this provider created are candidates for metadata sync. + updatable[key] = episode; + } + } + return (keys, updatable); } @@ -443,7 +580,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return changed; } - private void AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) + private Episode AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate) { var seasonNumber = season.IndexNumber.GetValueOrDefault(); var episodeNumber = (int)tmdbEpisode.EpisodeNumber; @@ -484,6 +621,49 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.Name); season.AddChild(episode); + + return episode; + } + + /// + /// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back + /// to the season/series image. + /// + /// The virtual episode. + /// The matching TMDb episode. + /// The cancellation token. + /// true if a still was downloaded and saved; otherwise false. + private async Task EnsureEpisodeImageAsync(Episode episode, TvSeasonEpisode tmdbEpisode, CancellationToken cancellationToken) + { + // The still ships with the season episode list, so use it directly instead of a per-episode lookup. + if (episode.HasImage(ImageType.Primary, 0) || string.IsNullOrEmpty(tmdbEpisode.StillPath)) + { + return false; + } + + var stillUrl = _tmdbClientManager.GetStillUrl(tmdbEpisode.StillPath); + if (string.IsNullOrEmpty(stillUrl)) + { + return false; + } + + try + { + // SaveImage sets the image path on the item but does not persist it, so save afterwards. + await _providerManager.SaveImage(episode, stillUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false); + await episode.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error downloading still for virtual episode S{SeasonNumber}E{EpisodeNumber} of {SeriesName}", + episode.ParentIndexNumber, + episode.IndexNumber, + episode.SeriesName); + return false; + } } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 174f1546a7..c8e3a7aa52 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -591,6 +591,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } + /// + /// Gets the absolute URL of an episode still. + /// + /// The relative URL of the still. + /// The absolute URL. + public string? GetStillUrl(string? stillPath) + { + return GetUrl(Plugin.Instance.Configuration.StillSize, stillPath); + } + /// /// Converts poster s into s. /// diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs index f4b7bb5b75..7813013c05 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs @@ -180,6 +180,88 @@ public class TmdbMissingEpisodeProviderTests Assert.Equal(2026, episode.ProductionYear); } + [Fact] + public void BuildSeasonSortNameTemplate_NoNameSortedPhysicalSeason_ReturnsNull() + { + // No physical season carries a forced (name-based) sort name -> virtual seasons keep their + // bare-index sort, so no template is produced. + var seasons = new[] + { + PhysicalSeason(1, forcedSortName: null), + VirtualSeason(3), + }; + + Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(seasons)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_MirrorsSiblingConventionAndSwapsNumber() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Season 01"), + VirtualSeason(3), + }); + + Assert.NotNull(template); + // Keeps the sibling's text token and zero-padding width, swapping in the target number. + Assert.Equal("Season 03", template!(3)); + Assert.Equal("Season 12", template(12)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_PreservesNonEnglishToken() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Staffel 1"), + VirtualSeason(2), + }); + + Assert.NotNull(template); + Assert.Equal("Staffel 2", template!(2)); + } + + [Fact] + public void BuildSeasonSortNameTemplate_SiblingWithoutDigits_ReturnsNull() + { + var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: "Miniseries"), + VirtualSeason(2), + }); + + Assert.Null(template); + } + + [Fact] + public void BuildSeasonSortNameTemplate_IgnoresVirtualSeasonsAsReference() + { + // A virtual season's own forced sort name must not be used as the convention source. + var virtualWithForced = VirtualSeason(3); + virtualWithForced.ForcedSortName = "Season 03"; + + Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[] + { + PhysicalSeason(1, forcedSortName: null), + virtualWithForced, + })); + } + + private static Season PhysicalSeason(int indexNumber, string? forcedSortName) + { + var season = new Season { IndexNumber = indexNumber, Path = $"/media/show/Season {indexNumber:00}" }; + if (!string.IsNullOrEmpty(forcedSortName)) + { + season.ForcedSortName = forcedSortName; + } + + return season; + } + + private static Season VirtualSeason(int indexNumber) + => new Season { IndexNumber = indexNumber, IsVirtualItem = true }; + private static Episode VirtualEpisode(DateTime premiereDate, bool withTmdbId, int? seasonNumber = null) { var episode = new Episode { PremiereDate = premiereDate, IsVirtualItem = true, ParentIndexNumber = seasonNumber }; -- cgit v1.2.3 From 79a55327dcb3899fb85147f7ec6b19cd71e5dcfb Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 27 Jul 2026 11:48:15 +0200 Subject: Fix extras naming and version assignment --- .../Library/LibraryManager.cs | 121 ++++++++-- .../Library/Resolvers/ExtraResolver.cs | 6 +- .../Localization/Core/en-US.json | 13 ++ .../Item/BaseItemRepository.TranslateQuery.cs | 23 +- MediaBrowser.Controller/Entities/BaseItem.cs | 52 ++++- MediaBrowser.Controller/Entities/Video.cs | 74 ++++++ MediaBrowser.Providers/Manager/ProviderManager.cs | 16 ++ .../Entities/BaseItemTests.cs | 64 ++++++ .../Library/LibraryManager/FindExtrasTests.cs | 250 +++++++++++++++++++-- 9 files changed, 561 insertions(+), 58 deletions(-) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 983ecced02..de44e2ada5 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -45,6 +45,7 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; @@ -86,6 +87,7 @@ namespace Emby.Server.Implementations.Library private readonly IPeopleRepository _peopleRepository; private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; + private readonly ILocalizationManager _localization; private readonly FastConcurrentLru _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; @@ -132,6 +134,7 @@ namespace Emby.Server.Implementations.Library /// The people repository. /// The path manager. /// The .ignore rule handler. + /// The localization manager. /// The media stream repository. /// The external data manager (lazy, to break the DI cycle through ChapterManager). public LibraryManager( @@ -157,6 +160,7 @@ namespace Emby.Server.Implementations.Library IPeopleRepository peopleRepository, IPathManager pathManager, DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + ILocalizationManager localization, IMediaStreamRepository mediaStreamRepository, Lazy externalDataManagerFactory) { @@ -184,6 +188,7 @@ namespace Emby.Server.Implementations.Library _peopleRepository = peopleRepository; _pathManager = pathManager; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; + _localization = localization; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -3280,9 +3285,11 @@ namespace Emby.Server.Implementations.Library var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath); if (ownerVideoInfo is null) { - yield break; + return []; } + var candidates = new List(); + var count = filtered.Count; for (var i = 0; i < count; i++) { @@ -3296,35 +3303,50 @@ namespace Emby.Server.Implementations.Library foreach (var file in filesInSubFolderList) { - if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType)) + if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { continue; } - var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder); - if (extra is not null) - { - yield return extra; - } + AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder); } } - else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType)) + else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule)) { - var extra = GetExtra(current, extraType.Value, false); - if (extra is not null) - { - yield return extra; - } + AddCandidate(current, extraType.Value, extraRule, false); + } + } + + var extras = new List(); + var typeCounters = new Dictionary(); + + // Order by path so that the numbering handed out below does not depend on the + // order the file system happened to list the folder in + foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal)) + { + var extra = PrepareExtra(candidate); + if (extra is not null) + { + extras.Add(extra); } } - BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder) + return extras; + + void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder) { var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType)); - if (extra is not Video && extra is not Audio) + if (extra is Video or Audio) { - return null; + candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder)); } + } + + BaseItem? PrepareExtra(ExtraCandidate candidate) + { + var resolved = candidate.Extra; + var extra = resolved; + var name = GetExtraName(candidate, ownerVideoInfo, typeCounters); // Try to retrieve it from the db. If we don't find it, use the resolved version var itemById = GetItemById(extra.Id); @@ -3333,10 +3355,18 @@ namespace Emby.Server.Implementations.Library extra = itemById; } + // An extra is named after its file, so the file is the source of truth. Items created + // by older versions, or renamed by a metadata provider, are corrected here; + // RefreshExtras persists the change. + if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true) + { + extra.Name = name; + } + // Only update extra type if it is more specific then the currently known extra type - if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown) + if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown) { - extra.ExtraType = extraType; + extra.ExtraType = candidate.ExtraType; } // Only return items that are actual extras (have ExtraType set) @@ -3344,7 +3374,7 @@ namespace Emby.Server.Implementations.Library // so that RefreshExtras can detect when they need updating and set ForceSave. if (extra.ExtraType is not null) { - extra.IsInMixedFolder = isInMixedFolder; + extra.IsInMixedFolder = candidate.IsInMixedFolder; return extra; } @@ -3352,6 +3382,57 @@ namespace Emby.Server.Implementations.Library } } + /// + /// Gets the name to give an extra. + /// + /// The resolved extra. + /// The naming info of the owner. + /// Number of extras named after their type so far, per type. + /// The name. + private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary typeCounters) + { + var isNamedAfterOwner = candidate.ExtraRule.RuleType switch + { + ExtraRuleType.Filename => true, + ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase), + _ => false + }; + + if (!isNamedAfterOwner) + { + return candidate.Extra.Name; + } + + typeCounters.TryGetValue(candidate.ExtraType, out var seen); + typeCounters[candidate.ExtraType] = seen + 1; + + var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType)); + + return seen == 0 + ? typeName + : string.Format( + CultureInfo.InvariantCulture, + _localization.GetServerLocalizedString("NameExtraNumbered"), + typeName, + seen + 1); + } + + private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch + { + ExtraType.Clip => "NameExtraClip", + ExtraType.Trailer => "NameExtraTrailer", + ExtraType.BehindTheScenes => "NameExtraBehindTheScenes", + ExtraType.DeletedScene => "NameExtraDeletedScene", + ExtraType.Interview => "NameExtraInterview", + ExtraType.Scene => "NameExtraScene", + ExtraType.Sample => "NameExtraSample", + ExtraType.ThemeSong => "NameExtraThemeSong", + ExtraType.ThemeVideo => "NameExtraThemeVideo", + ExtraType.Featurette => "NameExtraFeaturette", + ExtraType.Short => "NameExtraShort", + _ => "NameExtraUnknown" + }; + public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem) { foreach (var map in _configurationManager.Configuration.PathSubstitutions) @@ -3902,5 +3983,7 @@ namespace Emby.Server.Implementations.Library SetTopParentOrAncestorIds(query); return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType); } + + private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder); } } diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index 6ba4a7bce6..a0f75e4ddb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers _ => _videoResolvers }; - public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "") + public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "") { var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot); - if (extraResult.ExtraType is null) + if (extraResult.ExtraType is null || extraResult.Rule is null) { extraType = null; + extraRule = null; return false; } @@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } extraType = extraResult.ExtraType; + extraRule = extraResult.Rule; return isValid; } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 856941c61a..578c85da9d 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -28,6 +28,19 @@ "Movies": "Movies", "Music": "Music", "MusicVideos": "Music Videos", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 47f8a40b9c..525bb66c60 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1167,16 +1167,23 @@ public sealed partial class BaseItemRepository : baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder); } + // An extra is owned by the single version of an item it is named after, so an extra on any + // version counts for the item itself + IQueryable WithPrimaryVersions(IQueryable ownerIds) + => ownerIds.Concat(context.BaseItems + .Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id)) + .Select(version => version.PrimaryVersionId!.Value)); + if (filter.HasSpecialFeature.HasValue) { - var itemsWithExtras = context.BaseItems + var itemsWithExtras = WithPrimaryVersions(context.BaseItems .Where(extra => extra.OwnerId != null && extra.ExtraType != null && extra.ExtraType != BaseItemExtraType.Unknown && extra.ExtraType != BaseItemExtraType.Trailer && extra.ExtraType != BaseItemExtraType.ThemeSong && extra.ExtraType != BaseItemExtraType.ThemeVideo) - .Select(extra => extra.OwnerId!.Value) + .Select(extra => extra.OwnerId!.Value)) .Distinct(); Expression> hasExtras = e => itemsWithExtras.Contains(e.Id); @@ -1188,9 +1195,9 @@ public sealed partial class BaseItemRepository if (filter.HasTrailer.HasValue) { - var trailerOwnerIds = context.BaseItems + var trailerOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression> hasTrailer = e => trailerOwnerIds.Contains(e.Id); @@ -1201,9 +1208,9 @@ public sealed partial class BaseItemRepository if (filter.HasThemeSong.HasValue) { - var themeSongOwnerIds = context.BaseItems + var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id); @@ -1214,9 +1221,9 @@ public sealed partial class BaseItemRepository if (filter.HasThemeVideo.HasValue) { - var themeVideoOwnerIds = context.BaseItems + var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null) - .Select(extra => extra.OwnerId!.Value); + .Select(extra => extra.OwnerId!.Value)); Expression> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 209feac702..9c6d18d509 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -88,7 +88,7 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.Short }; - private static readonly char[] VersionDelimiters = ['-', '_', '.']; + private protected static readonly char[] VersionDelimiters = ['-', '_', '.']; private string _sortName; @@ -1543,19 +1543,33 @@ namespace MediaBrowser.Controller.Entities private async Task RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList fileSystemChildren, CancellationToken cancellationToken) { - var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); - var newExtraIds = Array.ConvertAll(extras, x => x.Id); - + // An extra is owned by the version it is named after, so all of them are maintained together. var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery() { - OwnerIds = [item.Id] - }); + OwnerIds = item.GetOwnedVersionIds() + }).Where(e => e.ExtraType.HasValue).ToList(); var currentExtraIds = currentExtras.Select(e => e.Id).ToArray(); + // Snapshot the persisted names before resolving, as FindExtras corrects the name on the + // items it hands back and may well hand back these very instances. + var currentExtraNames = new Dictionary(); + foreach (var extra in currentExtras) + { + currentExtraNames[extra.Id] = extra.Name; + } + + var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); + var newExtraIds = Array.ConvertAll(extras, x => x.Id); + + var renamedExtraIds = extras + .Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal)) + .Select(e => e.Id) + .ToHashSet(); + var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); - if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) + if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { // The owner's dates may only have become known after its extras were created, so keep // them in sync even when there is nothing to refresh. @@ -1570,12 +1584,11 @@ namespace MediaBrowser.Controller.Entities return false; } - var ownerId = item.Id; - var tasks = extras.Select(i => { + var ownerId = item.GetOwnerIdForExtra(i); var subOptions = new MetadataRefreshOptions(options); - if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) + if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id)) { subOptions.ForceSave = true; } @@ -2920,6 +2933,25 @@ namespace MediaBrowser.Controller.Entities return [Id]; } + /// + /// Gets the ids of this item and the versions of it whose extras it maintains. + /// + /// An array containing the version ids. + protected virtual Guid[] GetOwnedVersionIds() + { + return [Id]; + } + + /// + /// Gets the id of the version an extra belongs to. + /// + /// The extra. + /// The id of the owning version. + protected virtual Guid GetOwnerIdForExtra(BaseItem extra) + { + return Id; + } + /// /// Get all extras associated with this item, sorted by . /// diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 0606fe1870..5012378c52 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -751,6 +751,80 @@ namespace MediaBrowser.Controller.Entities .ToArray(); } + /// + protected override Guid[] GetOwnedVersionIds() + { + // Only the versions that live beside this one in the folder this scan covers. Linked + // versions are items of their own and maintain their extras themselves. + return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)]; + } + + /// + protected override Guid GetOwnerIdForExtra(BaseItem extra) + { + if (string.IsNullOrEmpty(extra.Path)) + { + return Id; + } + + var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan()); + var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan()); + + var ownerId = Id; + var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName); + + foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this)) + { + var version = LibraryManager.GetItemById(versionId); + if (version is null) + { + continue; + } + + // "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the + // primary version, whose name it also starts with when the primary is plain "Movie.mkv" + var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName); + if (length > matchedLength) + { + matchedLength = length; + ownerId = versionId; + } + } + + return ownerId; + } + + /// + /// Gets how much of an extra's file name is the name of the given version file, or 0 when the + /// extra is not named after it. + /// + /// The path of the version. + /// The directory the extra lives in. + /// The file name of the extra, without extension. + /// The length of the match. + private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan extraDirectory, ReadOnlySpan extraFileName) + { + if (string.IsNullOrEmpty(versionPath) + || !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan()); + if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + // The version name has to end where the extra's own name begins, so that a version + // named "Movie - 4K" does not claim the extras of "Movie - 4Kish" + var remainder = extraFileName[versionFileName.Length..]; + + return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0) + ? versionFileName.Length + : 0; + } + protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources() { var primary = PrimaryVersionId.HasValue diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 73df6d03d2..45fbe4d348 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -436,6 +436,14 @@ namespace MediaBrowser.Providers.Manager return false; } + // Extras have no identity of their own in an online database, so remote artwork for them + // is always some other item's. Local and dynamic providers still apply, so an extra can + // keep an embedded thumbnail or an extracted frame. + if (item.ExtraType.HasValue && provider is IRemoteImageProvider) + { + return false; + } + return _baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, provider.Name); } @@ -584,6 +592,14 @@ namespace MediaBrowser.Providers.Manager return true; } + // An extra is a local file belonging to another item and has no identity of its own in an + // online database. Looking it up matches whatever the surrounding folder happens to be + // called and overwrites the extra's name with a different item's title. + if (item.ExtraType.HasValue) + { + return false; + } + // Artists without a folder structure that are derived from metadata have no real path in the library, // so GetLibraryOptions returns null. Allow all providers through rather than blocking them. if (item is MusicArtist && libraryTypeOptions is null) diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index 258cf326ca..2a2da58674 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -443,4 +443,68 @@ public class BaseItemTests Assert.Equal(1982, trailer.ProductionYear); Assert.Equal(new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc), trailer.PremiereDate); } + + [Theory] + // An extra named after a version belongs to that version, not to the primary whose name it + // also starts with + [InlineData("/Movies/Movie/Movie - 4K-trailer.mkv", 2)] + [InlineData("/Movies/Movie/Movie - 1080p-behindthescenes.mkv", 1)] + // Named after the movie rather than one of its versions + [InlineData("/Movies/Movie/Movie-trailer.mkv", 0)] + // In an extras folder, so named after nothing in particular + [InlineData("/Movies/Movie/trailers/Official.mkv", 0)] + // A version name is only a match when it is followed by the extra's own suffix + [InlineData("/Movies/Movie/Movie - 4Kish-trailer.mkv", 0)] + public void GetOwnerIdForExtra_AssignsExtraToItsVersion(string extraPath, int expectedVersion) + { + var (primary, alt1, alt2) = SetupVersionGroup(); + var expectedId = expectedVersion switch + { + 1 => alt1.Id, + 2 => alt2.Id, + _ => primary.Id + }; + + var method = typeof(Video).GetMethod("GetOwnerIdForExtra", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var ownerId = (Guid)method!.Invoke(primary, [new Video { Id = Guid.NewGuid(), Path = extraPath }])!; + + Assert.Equal(expectedId, ownerId); + } + + [Fact] + public void GetExtraOwnerIds_FromAnyVersion_CoversEveryVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetExtraOwnerIds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // An extra is owned by the one version it is named after, and the extras of the movie as a + // whole are owned by the primary, so every version has to read all of them back + foreach (var version in new[] { primary, alt1, alt2 }) + { + var ids = (Guid[])method!.Invoke(version, null)!; + + Assert.Equal(3, ids.Length); + Assert.Contains(primary.Id, ids); + Assert.Contains(alt1.Id, ids); + Assert.Contains(alt2.Id, ids); + } + } + + [Fact] + public void GetOwnedVersionIds_CoversEveryLocalVersion() + { + var (primary, alt1, alt2) = SetupVersionGroup(); + + var method = typeof(Video).GetMethod("GetOwnedVersionIds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // The extras of all versions are maintained together, so all of them have to be read back + var ids = (Guid[])method!.Invoke(primary, null)!; + + Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs index 07c537aee1..a28c1d6dfb 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using AutoFixture; using AutoFixture.AutoMoq; using Emby.Naming.Common; @@ -17,6 +18,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using Moq; using Xunit; @@ -38,9 +40,15 @@ public class FindExtrasTests itemRepository.Setup(i => i.RetrieveItem(It.IsAny())).Returns(null); _fileSystemMock = fixture.Freeze>(); _fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny())).Returns(path => new FileSystemMetadata { FullName = path }); + + var strings = LoadCoreStrings(); + fixture.Freeze>() + .Setup(l => l.GetServerLocalizedString(It.IsAny())) + .Returns(key => strings.TryGetValue(key, out var value) ? value : key); + _libraryManager = fixture.Build().Do(s => s.AddParts( fixture.Create>(), - new List { new AudioResolver(fixture.Create()) }, + [new AudioResolver(fixture.Create())], fixture.Create>(), fixture.Create>(), fixture.Create>())) @@ -51,6 +59,16 @@ public class FindExtrasTests BaseItem.MediaSourceManager ??= fixture.Create(); } + private static Dictionary LoadCoreStrings() + { + using var stream = typeof(Emby.Server.Implementations.Library.LibraryManager).Assembly + .GetManifestResourceStream("Emby.Server.Implementations.Localization.Core.en-US.json") + ?? throw new InvalidOperationException("Core localization resource is missing"); + + return JsonSerializer.Deserialize>(stream) + ?? throw new InvalidOperationException("Core localization resource is empty"); + } + [Fact] public void FindExtras_SeparateMovieFolder_FindsCorrectExtras() { @@ -132,60 +150,60 @@ public class FindExtrasTests It.IsAny(), false, false)) - .Returns(new List - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/some trailer.mkv", Name = "some trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/behind the scenes", It.IsAny(), false, false)) - .Returns(new List - { + .Returns( + [ new() { FullName = "/movies/Up/behind the scenes/the making of Up.mkv", Name = "the making of Up.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/theme-music", It.IsAny(), false, false)) - .Returns(new List - { + .Returns( + [ new() { FullName = "/movies/Up/theme-music/theme2.mp3", Name = "theme2.mp3", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); _fileSystemMock.Setup(f => f.GetFiles( "/movies/Up/extras", It.IsAny(), false, false)) - .Returns(new List - { + .Returns( + [ new() { FullName = "/movies/Up/extras/Honest Trailer.mkv", Name = "Honest Trailer.mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var files = paths.Select(p => new FileSystemMetadata { @@ -289,15 +307,15 @@ public class FindExtrasTests It.IsAny(), false, false)) - .Returns(new List - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/trailer.jpg", Name = "trailer.jpg", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList(); @@ -320,15 +338,15 @@ public class FindExtrasTests It.IsAny(), false, false)) - .Returns(new List - { + .Returns( + [ new() { FullName = "/movies/Up/trailers/Trailer 1 (2013).mkv", Name = "Trailer 1 (2013).mkv", IsDirectory = false } - }).Verifiable(); + ]).Verifiable(); var files = paths.Select(p => new FileSystemMetadata { @@ -372,4 +390,198 @@ public class FindExtrasTests Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path); Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path); } + + [Fact] + public void FindExtras_SameExtraInSeveralContainers_ReturnsEach() + { + var owner = new Movie { Name = "Skyscraper", Path = "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv" }; + var paths = new List + { + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv", + "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + // A container is a separate file that plays on its own, so it is a separate extra + Assert.Equal(4, extras.Count); + Assert.Equal("Behind The Scenes", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"]); + Assert.Equal("Trailer", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4"]); + } + + [Fact] + public void FindExtras_SameExtraInSeveralResolutions_ReturnsEach() + { + var owner = new Movie { Name = "Dragon 2", Path = "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv" }; + var paths = new List + { + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv", + "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(2, extras.Count); + Assert.Equal("Trailer", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv"]); + Assert.Equal("Trailer 2", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"]); + } + + [Fact] + public void FindExtras_NumberedExtras_AreKeptApart() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mkv", + "/movies/Up (2009)/Up (2009)-trailer2.mp4", + "/movies/Up (2009)/Up (2009)-trailer3.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + Assert.Equal(4, extras.Count); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer.mkv", extras[0].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mkv", extras[1].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mp4", extras[2].Path); + Assert.Equal("/movies/Up (2009)/Up (2009)-trailer3.mkv", extras[3].Path); + + // The index in the file name is not the number the extra is given, which counts the + // extras of a type as they are found + Assert.Equal("Trailer", extras[0].Name); + Assert.Equal("Trailer 2", extras[1].Name); + Assert.Equal("Trailer 3", extras[2].Name); + Assert.Equal("Trailer 4", extras[3].Name); + } + + [Fact] + public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" }; + var paths = new List + { + "/movies/Up (2009)/Up (2009).mkv", + "/movies/Up (2009)/Up (2009)-trailer.mkv", + "/movies/Up (2009)/Recording the audio-behindthescenes.mkv", + "/movies/Up (2009)/Up (2009)-behindthescenes.mkv" + }; + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + IsDirectory = false + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + Assert.Equal(3, extras.Count); + Assert.Equal("Trailer", extras["/movies/Up (2009)/Up (2009)-trailer.mkv"]); + + // A descriptive file name is a real title and survives, and does not consume a number + Assert.Equal("Recording the audio", extras["/movies/Up (2009)/Recording the audio-behindthescenes.mkv"]); + Assert.Equal("Behind The Scenes", extras["/movies/Up (2009)/Up (2009)-behindthescenes.mkv"]); + } + + [Fact] + public void FindExtras_ExtraInOwnFolder_IsNamedAfterItsFile() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Comic-Con Reel.mkv", Name = "Comic-Con Reel.mkv", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)) + .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal); + + _fileSystemMock.Verify(); + Assert.Equal(2, extras.Count); + Assert.Equal("Teaser", extras["/movies/Up/trailers/Teaser.mkv"]); + Assert.Equal("Comic-Con Reel", extras["/movies/Up/trailers/Comic-Con Reel.mkv"]); + } + + [Fact] + public void FindExtras_DistinctExtrasInSameFolder_AreKeptApart() + { + var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" }; + var paths = new List + { + "/movies/Up/Up.mkv", + "/movies/Up/trailers" + }; + + _fileSystemMock.Setup(f => f.GetFiles( + "/movies/Up/trailers", + It.IsAny(), + false, + false)) + .Returns( + [ + new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mkv", Name = "Official.mkv", IsDirectory = false }, + new() { FullName = "/movies/Up/trailers/Official.mp4", Name = "Official.mp4", IsDirectory = false } + ]).Verifiable(); + + var files = paths.Select(p => new FileSystemMetadata + { + FullName = p, + Name = Path.GetFileName(p), + IsDirectory = !Path.HasExtension(p) + }).ToList(); + + var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + + _fileSystemMock.Verify(); + Assert.Equal(3, extras.Count); + Assert.Equal("/movies/Up/trailers/Official.mkv", extras[0].Path); + Assert.Equal("/movies/Up/trailers/Official.mp4", extras[1].Path); + Assert.Equal("/movies/Up/trailers/Teaser.mkv", extras[2].Path); + } } -- cgit v1.2.3 From 7a4271c85f98cfb4ebbe00e9786d17577330beac Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 29 Jul 2026 07:31:27 +0200 Subject: Fix video version links being read from stale serialised item data instead of the LinkedChildren table --- Jellyfin.Server.Implementations/Item/BaseItemMapper.cs | 15 +++++++++++++++ .../Item/BaseItemRepository.QueryBuilding.cs | 9 ++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index c64e6ac068..fe202b69ff 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -134,6 +134,21 @@ public static class BaseItemMapper if (dto is Video video) { video.PrimaryVersionId = entity.PrimaryVersionId; + + // The LinkedChildren table is the source of truth for version links + if (entity.LinkedChildEntities is not null) + { + video.LinkedAlternateVersions = entity.LinkedChildEntities + .Where(e => e.ChildType is Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion + or Database.Implementations.Entities.LinkedChildType.AutoLinkedAlternateVersion) + .OrderBy(e => e.SortOrder) + .Select(e => new LinkedChild + { + ItemId = e.ChildId, + Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType + }) + .ToArray(); + } } if (dto is IHasSeries hasSeriesName) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index a4de9feb05..3a166979ca 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -244,8 +244,8 @@ public sealed partial class BaseItemRepository dbQuery = dbQuery.Include(e => e.Images); } - // Include LinkedChildEntities for container types and videos that use them - // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions). + // Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist, + // CollectionFolder for manual linking; every video type for alternate versions). // When IncludeItemTypes is empty (any type may be returned), always include them to ensure // LinkedChildren are loaded before items are saved back, preventing accidental deletion. var linkedChildTypes = new[] @@ -254,7 +254,10 @@ public sealed partial class BaseItemRepository BaseItemKind.Playlist, BaseItemKind.CollectionFolder, BaseItemKind.Video, - BaseItemKind.Movie + BaseItemKind.Movie, + BaseItemKind.Episode, + BaseItemKind.MusicVideo, + BaseItemKind.Trailer }; if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) { -- cgit v1.2.3 From 1f4f4acb466153b698284bd31cf27d508fe72cb5 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 29 Jul 2026 09:39:04 +0200 Subject: Fixup --- Jellyfin.Server.Implementations/Item/BaseItemMapper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index fe202b69ff..7f211c9ea7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -139,8 +139,8 @@ public static class BaseItemMapper if (entity.LinkedChildEntities is not null) { video.LinkedAlternateVersions = entity.LinkedChildEntities - .Where(e => e.ChildType is Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion - or Database.Implementations.Entities.LinkedChildType.AutoLinkedAlternateVersion) + // LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here + .Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion) .OrderBy(e => e.SortOrder) .Select(e => new LinkedChild { -- cgit v1.2.3 From d8fc0a991433bd6ec9688ce16f944e21671fc945 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 29 Jul 2026 13:13:44 +0200 Subject: Fix AdjacentTo being ignored on non-recursive item queries --- .../Item/BaseItemRepository.QueryBuilding.cs | 29 ++++++++++++++++ .../Item/BaseItemRepository.Querying.cs | 2 ++ .../Item/BaseItemRepository.TranslateQuery.cs | 27 --------------- MediaBrowser.Controller/Entities/Folder.cs | 10 +----- .../Entities/UserViewBuilder.cs | 39 ++++++++++++++-------- 5 files changed, 58 insertions(+), 49 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index a4de9feb05..54926b7cd9 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -35,11 +35,40 @@ public sealed partial class BaseItemRepository { dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter); return dbQuery; } + /// + /// Trims an ordered query down to the AdjacentTo item and its immediate neighbours. + /// + private IQueryable ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable dbQuery, InternalItemsQuery filter) + { + if (filter.AdjacentTo.IsNullOrEmpty()) + { + return dbQuery; + } + + // Adjacency is relative to the result set and the order the query asked for, so the ids have + // to be read back in that order. + var orderedIds = dbQuery.Select(e => e.Id).ToList(); + var index = orderedIds.IndexOf(filter.AdjacentTo.Value); + if (index < 0) + { + // The item isn't part of this result set, so it has no neighbours in it either. + return dbQuery.Take(0); + } + + var start = Math.Max(index - 1, 0); + var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start); + + var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id)); + + return ApplyOrder(adjacentQuery, filter, context); + } + private IQueryable ApplyQueryPaging(IQueryable dbQuery, InternalItemsQuery filter) { if (filter.Limit.HasValue || filter.StartIndex.HasValue) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index 1ff8d8f863..b02d91b458 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); if (filter.EnableTotalRecordCount) { @@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 47f8a40b9c..1b73087296 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1243,33 +1243,6 @@ public sealed partial class BaseItemRepository } } - if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty()) - { - var adjacentToId = filter.AdjacentTo.Value; - var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault(); - if (targetItem is not null) - { - var targetSortName = targetItem.SortName ?? string.Empty; - - // Fetch both prev and next adjacent items in a single query using Concat (UNION ALL). - var adjacentIds = context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) < 0) - .OrderByDescending(e => e.SortName) - .Select(e => e.Id) - .Take(1) - .Concat( - context.BaseItems - .Where(e => string.Compare(e.SortName, targetSortName) > 0) - .OrderBy(e => e.SortName) - .Select(e => e.Id) - .Take(1)) - .ToList(); - - adjacentIds.Add(adjacentToId); - baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id)); - } - } - return baseQuery; } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..ed11f21f52 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1085,15 +1085,7 @@ namespace MediaBrowser.Controller.Entities items = ApplyNameFilter(items, query); } - var filteredItems = items as IReadOnlyList ?? items.ToList(); - var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); - - if (query.EnableTotalRecordCount) - { - result.TotalRecordCount = filteredItems.Count; - } - - return result; + return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); } private static IEnumerable ApplyNameFilter(IEnumerable items, InternalItemsQuery query) diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 9ba103cc8b..fe7866fe65 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -490,6 +490,13 @@ namespace MediaBrowser.Controller.Entities } var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray(); + + // Adjacency is defined by the order the query asked for, so it has to run after sorting but before paging. + if (!query.AdjacentTo.IsNullOrEmpty()) + { + itemsArray = FilterForAdjacency(itemsArray, query.AdjacentTo.Value).ToArray(); + } + var totalCount = itemsArray.Length; if (query.Limit.HasValue && query.Limit.Value > 0) @@ -886,26 +893,32 @@ namespace MediaBrowser.Controller.Entities return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName); } - public static IEnumerable FilterForAdjacency(List list, Guid adjacentTo) + /// + /// Trims an ordered list down to the requested item and its immediate neighbours. + /// + /// The items in the order the query returned them. + /// The id of the item to return the neighbours of. + /// The previous item, the requested item and the next item, in order. + public static IEnumerable FilterForAdjacency(IReadOnlyList list, Guid adjacentTo) { - var adjacentToItem = list.FirstOrDefault(i => i.Id.Equals(adjacentTo)); - - var index = list.IndexOf(adjacentToItem); - - var previousId = Guid.Empty; - var nextId = Guid.Empty; - - if (index > 0) + var index = -1; + for (var i = 0; i < list.Count; i++) { - previousId = list[index - 1].Id; + if (list[i].Id.Equals(adjacentTo)) + { + index = i; + break; + } } - if (index < list.Count - 1) + // The item isn't part of this result set, so it has no neighbours in it either. + if (index < 0) { - nextId = list[index + 1].Id; + return []; } - return list.Where(i => i.Id.Equals(previousId) || i.Id.Equals(nextId) || i.Id.Equals(adjacentTo)); + var start = Math.Max(index - 1, 0); + return list.Skip(start).Take(Math.Min(index + 2, list.Count) - start); } } } -- cgit v1.2.3 From f28acc7fa1007503690cfdd8b2b8a0edea182f4f Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Fri, 31 Jul 2026 00:34:43 -0400 Subject: Use CleanName when sorting by name --- Jellyfin.Server.Implementations/Item/OrderMapper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index 25ad81ec6c..00b10e44a9 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -68,7 +68,7 @@ public static class OrderMapper (ItemSortBy.DateCreated, _) => e => e.DateCreated, (ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null), (ItemSortBy.StartDate, _) => e => e.StartDate, - (ItemSortBy.Name, _) => e => e.SortName, + (ItemSortBy.Name, _) => e => e.CleanName, (ItemSortBy.CommunityRating, _) => e => e.CommunityRating, (ItemSortBy.ProductionYear, _) => e => e.ProductionYear, (ItemSortBy.CriticRating, _) => e => e.CriticRating, -- cgit v1.2.3 From d4376e0539577e922b99fd300520d78909c65aae Mon Sep 17 00:00:00 2001 From: altqx Date: Sat, 1 Aug 2026 22:49:34 +0700 Subject: Allow client-rendered graphical subtitles during remux --- MediaBrowser.Model/Dlna/StreamBuilder.cs | 6 ++- .../Dlna/StreamBuilderTests.cs | 46 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index a9ab7d6db0..ab8d5dd5b2 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1582,7 +1582,11 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!subtitleStream.IsExternal && playMethod == PlayMethod.Transcode && !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec)) + if (!subtitleStream.IsExternal + && playMethod == PlayMethod.Transcode + && !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec) + && !subtitleStream.IsPgsSubtitleStream + && !subtitleStream.IsVobSubSubtitleStream) { continue; } diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 5ba061296a..f5a023686c 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -371,6 +371,45 @@ namespace Jellyfin.Model.Tests Assert.Equal(streamInfo?.SubtitleStreamIndex, options.SubtitleStreamIndex); } + [Theory] + [InlineData("pgssub", null)] + [InlineData("vobsub", "mks")] + public async Task BuildVideoItemWithSecondaryAudioAndExternalGraphicalSubtitleKeepsVideoCopy(string subtitleCodec, string? subtitleContainer) + { + var options = await GetMediaOptions("Chrome", "mp4-h264-ac3-aac-srt-2600k"); + var subtitleStream = options.MediaSources[0].MediaStreams[^1]; + subtitleStream.Codec = subtitleCodec; + subtitleStream.IsExternal = false; + subtitleStream.SupportsExternalStream = true; + subtitleStream.Path = null; + + options.Profile.SubtitleProfiles = + [ + new SubtitleProfile + { + Format = subtitleCodec, + Container = subtitleContainer, + Method = SubtitleDeliveryMethod.External + } + ]; + options.AudioStreamIndex = 2; + options.SubtitleStreamIndex = subtitleStream.Index; + + var streamInfo = GetStreamBuilder(enableSubtitleExtraction: false).GetOptimalVideoStream(options); + + Assert.NotNull(streamInfo); + Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod); + Assert.Equal(TranscodeReason.SecondaryAudioNotSupported, streamInfo.TranscodeReasons); + Assert.Equal(SubtitleDeliveryMethod.External, streamInfo.SubtitleDeliveryMethod); + Assert.Contains("h264", streamInfo.VideoCodecs); + Assert.Contains("aac", streamInfo.AudioCodecs); + + var queryString = streamInfo.ToUrl("media:", "ACCESSTOKEN", null).Split('?', 2).ElementAtOrDefault(1); + var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty); + Assert.Null(query["SubtitleStreamIndex"]); + Assert.Null(query["SubtitleMethod"]); + } + private StreamInfo? BuildVideoItemSimpleTest(MediaOptions options, PlayMethod? playMethod, TranscodeReason why, string transcodeMode, string transcodeProtocol) { if (string.IsNullOrEmpty(transcodeProtocol)) @@ -573,9 +612,10 @@ namespace Jellyfin.Model.Tests throw new SerializationException("Invalid test data: " + name); } - private StreamBuilder GetStreamBuilder() + private StreamBuilder GetStreamBuilder(bool enableSubtitleExtraction = false) { var transcodeSupport = new Mock(); + transcodeSupport.Setup(t => t.CanExtractSubtitles(It.IsAny())).Returns(enableSubtitleExtraction); var logger = new NullLogger(); return new StreamBuilder(transcodeSupport.Object, logger); @@ -625,7 +665,7 @@ namespace Jellyfin.Model.Tests // EnableSubtitleExtraction = false, internal subtitles [InlineData("srt", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] [InlineData("srt", "srt", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)] - [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] + [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.External)] [InlineData("pgssub", "pgssub", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)] [InlineData("pgssub", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)] // EnableSubtitleExtraction = false, external subtitles @@ -678,7 +718,7 @@ namespace Jellyfin.Model.Tests [Theory] [InlineData(false, null, true, SubtitleDeliveryMethod.External)] - [InlineData(false, null, false, SubtitleDeliveryMethod.Encode)] + [InlineData(false, null, false, SubtitleDeliveryMethod.External)] [InlineData(true, "/media/sub.mks", true, SubtitleDeliveryMethod.External)] [InlineData(true, "/media/sub.idx", true, SubtitleDeliveryMethod.Encode)] [InlineData(true, "/media/sub.sub", true, SubtitleDeliveryMethod.Encode)] -- cgit v1.2.3 From 72d0895401727b45d67a5b032c0590d2dfc8faae Mon Sep 17 00:00:00 2001 From: krvi <27792771+krvi@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:01:21 -0400 Subject: Translated using Weblate (Faroese) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/ --- Emby.Server.Implementations/Localization/Core/fo.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 3b0bd2ed92..39c13b19a9 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -83,5 +83,16 @@ "TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir", "TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir", "CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.", - "CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur" + "CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur", + "TaskRefreshPeople": "Dagfør persónsupplýsingar", + "TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.", + "TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.", + "TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.", + "NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu", + "TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.", + "TaskKeyframeExtractor": "Lyklamyndaúttøka", + "TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.", + "TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.", + "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", + "TaskRefreshChapterImages": "Kapitlamyndaúttøkur" } -- cgit v1.2.3 From ae90e0e52efc0ec71512d58ef344e25ed9354fd1 Mon Sep 17 00:00:00 2001 From: krvi <27792771+krvi@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:38:15 -0400 Subject: Translated using Weblate (Faroese) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/ --- Emby.Server.Implementations/Localization/Core/fo.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 39c13b19a9..235b1967ae 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -94,5 +94,9 @@ "TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.", "TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.", "TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.", - "TaskRefreshChapterImages": "Kapitlamyndaúttøkur" + "TaskRefreshChapterImages": "Kapitlamyndaúttøkur", + "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", + "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað", + "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað" } -- cgit v1.2.3 From 26261dbfe7e8debc76f6bd4546bb6ef4e072bbf9 Mon Sep 17 00:00:00 2001 From: krvi <27792771+krvi@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:33:27 -0400 Subject: Translated using Weblate (Faroese) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/ --- Emby.Server.Implementations/Localization/Core/fo.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 235b1967ae..6c3d33ba7b 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -98,5 +98,8 @@ "NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað", "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað", "NotificationOptionAudioPlayback": "Ljóðspæl byrjað", - "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað" + "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað", + "TaskExtractMediaSegments": "Leita eftir margmiðlabrotum", + "TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.", + "NotificationOptionCameraImageUploaded": "Ljósmynd uppsent" } -- cgit v1.2.3 From 4c812c9ba4b58a30c24b3cebbfdf2d33969bb3e0 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 2 Aug 2026 18:02:22 +0200 Subject: Switch to opt-in --- .../Tmdb/Configuration/PluginConfiguration.cs | 10 ++++----- .../Plugins/Tmdb/Configuration/config.html | 17 +++++++++------ .../Plugins/Tmdb/TV/TmdbMissingEpisodeProvider.cs | 24 ++++++++------------- .../Plugins/Tmdb/TV/TmdbUpcomingEpisodesTask.cs | 25 +++++++++------------- 4 files changed, 34 insertions(+), 42 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 0ebeebf1e2..b3a67189bb 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -58,12 +58,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// /// Gets or sets the ids (the "N" formatted GUIDs from VirtualFolderInfo.ItemId) of the - /// libraries for which the unaired/missing episode provider is disabled. Whether episodes are - /// imported at all, and how, is still controlled by the global toggles above; this list only - /// opts individual libraries out. Libraries not listed here are enabled, so the global toggles - /// apply to every library unless it is explicitly opted out. + /// libraries for which the unaired/missing episode provider is enabled. Whether episodes are + /// imported at all, and how, is still controlled by the global toggles above; those toggles only + /// apply to the libraries listed here. Libraries not listed, including newly added ones, are + /// never processed, so an empty list disables the feature entirely. /// - public string[] DisabledMissingEpisodeLibraries { get; set; } = []; + public string[] EnabledMissingEpisodeLibraries { get; set; } = []; /// /// Gets or sets how often, in days, the scheduled task re-checks TMDb for newly announced diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index b010749936..582753759f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -56,7 +56,7 @@

Libraries

-
Choose which TV libraries the unaired/missing episode options above apply to. The settings above are global; this only controls which libraries they run for. New libraries are enabled by default.
+
Choose which TV libraries the unaired/missing episode options above apply to. The settings above are global; this only controls which libraries they run for. Libraries are opted in individually, so newly added libraries are not processed until they are enabled here.
@@ -119,7 +119,7 @@ Dashboard.showLoadingMsg(); var clientConfig, pluginConfig; - var populateMissingEpisodeLibraries = function (disabledLibraries) { + var populateMissingEpisodeLibraries = function (enabledLibraries) { var container = document.querySelector('#missingEpisodeLibraries'); ApiClient.getVirtualFolders().then(function (folders) { // Series only live in TV libraries (and mixed-content libraries, which report @@ -134,7 +134,7 @@ } container.innerHTML = tvLibraries.map(function (folder) { - var checked = disabledLibraries.indexOf(folder.ItemId) === -1 ? ' checked' : ''; + var checked = enabledLibraries.indexOf(folder.ItemId) === -1 ? '' : ' checked'; return '