mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-07-06 06:12:52 +01:00
Merge remote-tracking branch 'upstream/master' into version-model-and-handling
This commit is contained in:
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -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 # -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
|
||||
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
|
||||
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
|
||||
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}"
|
||||
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
|
||||
"Original": "Πρωτότυπο"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"CleanupUserDataTask": "Задатак чишћења корисничких података",
|
||||
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
|
||||
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
|
||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
|
||||
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
|
||||
"Original": "Изворно"
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask
|
||||
EnableImages = false
|
||||
},
|
||||
SourceTypes = [SourceType.Library],
|
||||
IsVirtualItem = false
|
||||
IsVirtualItem = false,
|
||||
IncludeOwnedItems = true
|
||||
})
|
||||
.OfType<Video>()
|
||||
.ToList();
|
||||
|
||||
@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
|
||||
DtoOptions = new DtoOptions(true),
|
||||
SourceTypes = [SourceType.Library],
|
||||
Recursive = true,
|
||||
IncludeOwnedItems = true,
|
||||
Limit = pagesize
|
||||
};
|
||||
|
||||
|
||||
@@ -981,6 +981,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
MediaTypes = mediaTypes,
|
||||
IsVirtualItem = false,
|
||||
CollapseBoxSetItems = false,
|
||||
IncludeOwnedItems = true,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
AncestorIds = ancestorIds,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
|
||||
@@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController
|
||||
Request.HttpContext.GetNormalizedRemoteIP());
|
||||
}
|
||||
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
|
||||
}
|
||||
|
||||
if (autoOpenLiveStream.Value)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,6 +60,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask
|
||||
DtoOptions = new DtoOptions(true),
|
||||
SourceTypes = [SourceType.Library],
|
||||
Recursive = true,
|
||||
IncludeOwnedItems = true,
|
||||
Limit = Pagesize
|
||||
};
|
||||
|
||||
|
||||
99
tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
Normal file
99
tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user