Merge remote-tracking branch 'upstream/master' into version-model-and-handling

This commit is contained in:
Shadowghost
2026-06-10 08:05:03 +02:00
17 changed files with 204 additions and 28 deletions

View File

@@ -1,11 +1,15 @@
<!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation.
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
-->
**Changes**
<!-- Describe your changes here in 1-5 sentences. -->
**Code assistance**
<!-- If code assistance was used, describe how it contributed
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
**Issues**
<!-- Tag any issues that this PR solves here.
ex. Fixes # -->

View File

@@ -2450,8 +2450,14 @@ namespace Emby.Server.Implementations.Library
var outdated = forceUpdate
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
: item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
// Skip image processing if current or live tv source
if (outdated.Length == 0 || item.SourceType != SourceType.Library)
var parentItem = item.GetParent();
var isLiveTvShow = item.SourceType != SourceType.Library &&
parentItem is not null &&
parentItem.SourceType != SourceType.Library; // not a channel
// Skip image processing if current or live tv show
if (outdated.Length == 0 || isLiveTvShow)
{
RegisterItem(item);
return;

View File

@@ -390,6 +390,12 @@ namespace Emby.Server.Implementations.Library
if (user is not null)
{
sources = sources
.Where(source => !Guid.TryParse(source.Id, out var sourceId)
|| sourceId.Equals(item.Id)
|| _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
.ToArray();
foreach (var source in sources)
{
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -610,24 +616,32 @@ namespace Emby.Server.Implementations.Library
}
}
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
{
return sources.OrderBy(i =>
{
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
// The source belonging to the queried item sorts first so it stays the default that gets played.
var preferredId = preferredItemId.IsEmpty()
? null
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
return sources
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
.ThenBy(i =>
{
return 0;
}
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
{
return 0;
}
return 1;
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
.ThenByDescending(i =>
{
var stream = i.VideoStream;
return 1;
})
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
.ThenByDescending(i =>
{
var stream = i.VideoStream;
return stream?.Width ?? 0;
})
.Where(i => i.Type != MediaSourceType.Placeholder);
return stream?.Width ?? 0;
})
.Where(i => i.Type != MediaSourceType.Placeholder);
}
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)

View File

@@ -107,5 +107,6 @@
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}"
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
"Original": "Πρωτότυπο"
}

View File

@@ -106,5 +106,7 @@
"CleanupUserDataTask": "Задатак чишћења корисничких података",
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
"Original": "Изворно"
}

View File

@@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask
EnableImages = false
},
SourceTypes = [SourceType.Library],
IsVirtualItem = false
IsVirtualItem = false,
IncludeOwnedItems = true
})
.OfType<Video>()
.ToList();

View File

@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
IncludeOwnedItems = true,
Limit = pagesize
};

View File

@@ -981,6 +981,7 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes,

View File

@@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP());
}
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
}
if (autoOpenLiveStream.Value)

View File

@@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP());
}
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
foreach (var source in info.MediaSources)
{

View File

@@ -351,11 +351,20 @@ public class MediaInfoHelper
/// </summary>
/// <param name="result">Playback info response.</param>
/// <param name="maxBitrate">Max bitrate.</param>
public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
/// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param>
public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default)
{
var originalList = result.MediaSources.ToList();
result.MediaSources = result.MediaSources.OrderBy(i =>
// The queried item's source carries the user's resume state for that version, so it must stay the
// default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version.
var preferredId = preferredItemId.IsEmpty()
? null
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
result.MediaSources = result.MediaSources
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
.ThenBy(i =>
{
// Nothing beats direct playing a file
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)

View File

@@ -557,9 +557,11 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
// Deduplicate; local (file-based) relationships take priority over linked (user-merged)
// ones, matching the LinkedChildren migration.
newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
.ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();

View File

@@ -223,6 +223,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
// Drop linked (user-merged) entries that point at items the parent owns (local
// file-based alternates or extras). These stem from legacy data that merged an
// owned item onto its own primary and would wrongly mark server-merged groups
// as user-merged (splittable).
var linkedChildIds = toInsert
.Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion)
.Select(lc => lc.ChildId)
.Distinct()
.ToList();
if (linkedChildIds.Count > 0)
{
var ownerIdByChildId = context.BaseItems
.WhereOneOrMany(linkedChildIds, b => b.Id)
.Where(b => b.OwnerId.HasValue)
.Select(b => new { b.Id, b.OwnerId })
.ToDictionary(b => b.Id, b => b.OwnerId!.Value);
var removedCount = toInsert.RemoveAll(lc =>
lc.ChildType == LinkedChildType.LinkedAlternateVersion
&& ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId)
&& ownerId.Equals(lc.ParentId));
if (removedCount > 0)
{
_logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount);
}
}
context.LinkedChildren.AddRange(toInsert);
context.SaveChanges();

View File

@@ -102,7 +102,8 @@ namespace MediaBrowser.Providers.MediaInfo
DtoOptions = new DtoOptions(true),
SourceTypes = new[] { SourceType.Library },
Parent = library,
Recursive = true
Recursive = true,
IncludeOwnedItems = true
};
if (skipIfAudioTrackMatches)

View File

@@ -448,14 +448,19 @@ public class GuideManager : IGuideManager
item.Name = channelInfo.Name;
if (!item.HasImage(ImageType.Primary))
var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
// Update channel image if image URL has changed
if (currentPrimary is null
|| (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
{
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
{
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
forceUpdate = true;
}
else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
else if (!imageUrlIsNull)
{
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
forceUpdate = true;

View File

@@ -60,6 +60,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
IncludeOwnedItems = true,
Limit = Pagesize
};

View File

@@ -0,0 +1,99 @@
using System;
using System.Globalization;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.Api.Tests.Helpers
{
public class MediaInfoHelperTests
{
private static MediaInfoHelper CreateHelper()
{
return new MediaInfoHelper(
Mock.Of<IUserManager>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IMediaEncoder>(),
Mock.Of<IServerConfigurationManager>(),
Mock.Of<ILogger<MediaInfoHelper>>(),
Mock.Of<INetworkManager>(),
Mock.Of<IDeviceManager>());
}
private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
{
return new MediaSourceInfo
{
Id = itemId.ToString("N", CultureInfo.InvariantCulture),
Protocol = MediaProtocol.File,
Bitrate = bitrate,
SupportsDirectPlay = supportsDirectPlay,
SupportsDirectStream = true,
SupportsTranscoding = true
};
}
[Fact]
public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault()
{
// The version the user was watching (the queried item) must stay the default
// even when a sibling version fits the bitrate limit better, since the resume
// position belongs to that exact version.
var preferredItemId = Guid.NewGuid();
var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false);
var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
var result = new PlaybackInfoResponse
{
MediaSources = [siblingSource, preferredSource]
};
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId);
Assert.Equal(preferredSource.Id, result.MediaSources[0].Id);
}
[Fact]
public void SortMediaSources_NoPreferredItem_OrdersByPlayability()
{
var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
transcodeOnly.SupportsDirectStream = false;
var result = new PlaybackInfoResponse
{
MediaSources = [transcodeOnly, directPlay]
};
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000);
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
}
[Fact]
public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder()
{
var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
transcodeOnly.SupportsDirectStream = false;
var result = new PlaybackInfoResponse
{
MediaSources = [transcodeOnly, directPlay]
};
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid());
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
}
}
}