mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-07-06 06:12:52 +01:00
Merge pull request #17044 from Shadowghost/version-model-and-handling
Fixes for multi version handling
This commit is contained in:
@@ -87,6 +87,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
Model.Entities.ExtraType.Short
|
||||
};
|
||||
|
||||
private static readonly char[] VersionDelimiters = ['-', '_', '.'];
|
||||
|
||||
private string _sortName;
|
||||
|
||||
private string _forcedSortName;
|
||||
@@ -1099,8 +1101,9 @@ namespace MediaBrowser.Controller.Entities
|
||||
}
|
||||
}
|
||||
|
||||
var list = GetAllItemsForMediaSources();
|
||||
var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
|
||||
var list = GetAllItemsForMediaSources().ToList();
|
||||
var commonPrefix = GetCommonNamePrefix(list);
|
||||
var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
|
||||
|
||||
if (IsActiveRecording())
|
||||
{
|
||||
@@ -1110,17 +1113,15 @@ namespace MediaBrowser.Controller.Entities
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(i =>
|
||||
{
|
||||
if (i.VideoType == VideoType.VideoFile)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// The source belonging to the item being queried sorts first so it is the default the client plays.
|
||||
var selfId = Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
return 1;
|
||||
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||
.ThenByDescending(i => i, new MediaSourceWidthComparator())
|
||||
.ToArray();
|
||||
return result
|
||||
.OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1)
|
||||
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||
.ThenByDescending(i => i, new MediaSourceWidthComparator())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
|
||||
@@ -1128,7 +1129,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
|
||||
}
|
||||
|
||||
private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
|
||||
private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
@@ -1141,7 +1142,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
Protocol = protocol ?? MediaProtocol.File,
|
||||
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
|
||||
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
|
||||
Name = GetMediaSourceName(item),
|
||||
Name = GetMediaSourceName(item, commonPrefix),
|
||||
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
|
||||
RunTimeTicks = item.RunTimeTicks,
|
||||
Container = item.Container,
|
||||
@@ -1220,7 +1221,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
return info;
|
||||
}
|
||||
|
||||
internal string GetMediaSourceName(BaseItem item)
|
||||
internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
|
||||
{
|
||||
var terms = new List<string>();
|
||||
|
||||
@@ -1228,12 +1229,31 @@ namespace MediaBrowser.Controller.Entities
|
||||
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
|
||||
{
|
||||
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
|
||||
if (HasLocalAlternateVersions)
|
||||
|
||||
// Prefer the suffix that differs from the other versions: strip the prefix shared by
|
||||
// all sibling files. This works regardless of folder layout, so it also labels episode
|
||||
// versions that share a season folder (e.g. "Greyscale" instead of the full
|
||||
// "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter
|
||||
// boundary (see GetCommonVersionPrefix).
|
||||
if (!string.IsNullOrEmpty(commonPrefix)
|
||||
&& displayName.Length > commonPrefix.Length
|
||||
&& displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]);
|
||||
if (!name.IsWhiteSpace())
|
||||
{
|
||||
terms.Add(name.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the containing folder name (the common layout for movie versions, and
|
||||
// the path taken when no common prefix could be derived).
|
||||
if (terms.Count == 0 && HasLocalAlternateVersions)
|
||||
{
|
||||
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
|
||||
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
|
||||
var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]);
|
||||
if (!name.IsWhiteSpace())
|
||||
{
|
||||
terms.Add(name.ToString());
|
||||
@@ -1290,6 +1310,98 @@ namespace MediaBrowser.Controller.Entities
|
||||
return string.Join('/', terms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the prefix shared by the supplied media source items' file names, used to strip the
|
||||
/// common part and surface a short version label per source. Returns null when there are fewer
|
||||
/// than two file-based sources, since there is nothing to differentiate.
|
||||
/// </summary>
|
||||
/// <param name="items">The media source items.</param>
|
||||
/// <returns>The shared prefix, or null when no useful prefix exists.</returns>
|
||||
private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
|
||||
{
|
||||
var fileNames = new List<string>();
|
||||
foreach (var (item, _) in items)
|
||||
{
|
||||
if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
|
||||
{
|
||||
fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
|
||||
}
|
||||
}
|
||||
|
||||
if (fileNames.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefix = GetCommonVersionPrefix(fileNames);
|
||||
return string.IsNullOrEmpty(prefix) ? null : prefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the case-insensitive longest common prefix of the supplied version file names,
|
||||
/// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact:
|
||||
/// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
|
||||
/// "Greyish") while still trimming the common part when every version is suffixed (e.g.
|
||||
/// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a
|
||||
/// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is
|
||||
/// kept in the label, falling back to a space only when no structural delimiter is shared. The
|
||||
/// separators mirror the version delimiters recognised by the naming layer (Emby.Naming
|
||||
/// VideoFlagDelimiters).
|
||||
/// </summary>
|
||||
/// <param name="fileNames">The version file names without extension; must contain at least one entry.</param>
|
||||
/// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns>
|
||||
internal static string GetCommonVersionPrefix(IReadOnlyList<string> fileNames)
|
||||
{
|
||||
var prefix = fileNames[0];
|
||||
for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
|
||||
{
|
||||
var name = fileNames[i];
|
||||
var length = Math.Min(prefix.Length, name.Length);
|
||||
var common = 0;
|
||||
while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
|
||||
{
|
||||
common++;
|
||||
}
|
||||
|
||||
prefix = prefix[..common];
|
||||
}
|
||||
|
||||
// If the common prefix is itself a whole file name then one version is unlabelled (the
|
||||
// base name); the boundary already sits at the end of that name, so don't retreat into it.
|
||||
var prefixIsWholeName = false;
|
||||
for (var i = 0; i < fileNames.Count; i++)
|
||||
{
|
||||
if (fileNames[i].Length == prefix.Length)
|
||||
{
|
||||
prefixIsWholeName = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefixIsWholeName)
|
||||
{
|
||||
// Retreat to the last structural delimiter ('-', '_', '.').
|
||||
var cut = prefix.Length;
|
||||
while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
|
||||
{
|
||||
cut--;
|
||||
}
|
||||
|
||||
if (cut == 0)
|
||||
{
|
||||
cut = prefix.Length;
|
||||
while (cut > 0 && prefix[cut - 1] != ' ')
|
||||
{
|
||||
cut--;
|
||||
}
|
||||
}
|
||||
|
||||
prefix = prefix[..cut];
|
||||
}
|
||||
|
||||
return prefix;
|
||||
}
|
||||
|
||||
public Task RefreshMetadata(CancellationToken cancellationToken)
|
||||
{
|
||||
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
|
||||
@@ -2011,12 +2123,23 @@ namespace MediaBrowser.Controller.Entities
|
||||
// I think it is okay to do this here.
|
||||
// if this is only called when a user is manually forcing something to un-played
|
||||
// then it probably is what we want to do...
|
||||
ResetPlayedState(data);
|
||||
|
||||
UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the played state on the supplied user data.
|
||||
/// </summary>
|
||||
/// <param name="data">The user data to reset.</param>
|
||||
protected static void ResetPlayedState(UserItemData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
data.PlayCount = 0;
|
||||
data.PlaybackPositionTicks = 0;
|
||||
data.LastPlayedDate = null;
|
||||
data.Played = false;
|
||||
|
||||
UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2731,6 +2854,15 @@ namespace MediaBrowser.Controller.Entities
|
||||
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ids of the items whose owned extras belong to this item.
|
||||
/// </summary>
|
||||
/// <returns>An array containing the owner ids.</returns>
|
||||
protected virtual Guid[] GetExtraOwnerIds()
|
||||
{
|
||||
return [Id];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
|
||||
/// </summary>
|
||||
@@ -2740,7 +2872,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
return LibraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
OwnerIds = [Id],
|
||||
OwnerIds = GetExtraOwnerIds(),
|
||||
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
|
||||
});
|
||||
}
|
||||
@@ -2755,7 +2887,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
return LibraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
OwnerIds = [Id],
|
||||
OwnerIds = GetExtraOwnerIds(),
|
||||
ExtraTypes = extraTypes.ToArray(),
|
||||
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
|
||||
});
|
||||
|
||||
@@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
public Video()
|
||||
{
|
||||
AdditionalParts = Array.Empty<string>();
|
||||
LocalAlternateVersions = Array.Empty<string>();
|
||||
SubtitleFiles = Array.Empty<string>();
|
||||
AudioFiles = Array.Empty<string>();
|
||||
LinkedAlternateVersions = Array.Empty<LinkedChild>();
|
||||
AdditionalParts = [];
|
||||
LocalAlternateVersions = [];
|
||||
SubtitleFiles = [];
|
||||
AudioFiles = [];
|
||||
LinkedAlternateVersions = [];
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
@@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
|
||||
{
|
||||
callstack ??= new();
|
||||
callstack ??= [];
|
||||
if (PrimaryVersionId.HasValue)
|
||||
{
|
||||
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
|
||||
@@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities
|
||||
PresentationUniqueKey = CreatePresentationUniqueKey();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the played status of this video and propagates it to its alternate versions.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="datePlayed">The date played.</param>
|
||||
/// <param name="resetPosition">if set to <c>true</c> [reset position].</param>
|
||||
public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
|
||||
{
|
||||
base.MarkPlayed(user, datePlayed, resetPosition);
|
||||
PropagatePlayedState(user, true, resetPosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this video unplayed and propagates the change to its alternate versions.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
public override void MarkUnplayed(User user)
|
||||
{
|
||||
base.MarkUnplayed(user);
|
||||
|
||||
// MarkUnplayed always clears the position on this video, so reset the versions too.
|
||||
PropagatePlayedState(user, false, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates the played status to every alternate version of this video.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="played">The played status to apply to the alternate versions.</param>
|
||||
/// <param name="resetPosition">When marking played, controls whether each version's resume point
|
||||
/// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed,
|
||||
/// which always fully resets every version.</param>
|
||||
public void PropagatePlayedState(User user, bool played, bool resetPosition = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (item, _) in GetAllItemsForMediaSources())
|
||||
{
|
||||
if (item.Id.Equals(Id) || item is not Video)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (played)
|
||||
{
|
||||
var dto = new UpdateUserItemDataDto { Played = true };
|
||||
if (resetPosition)
|
||||
{
|
||||
dto.PlaybackPositionTicks = 0;
|
||||
}
|
||||
|
||||
// SaveUserData only writes the fields set on the DTO, so play count and other state are preserved.
|
||||
UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed);
|
||||
}
|
||||
else
|
||||
{
|
||||
var data = UserDataManager.GetUserData(user, item);
|
||||
if (data is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ResetPlayedState(data);
|
||||
UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this video together with all of its alternate versions (local and linked and, when this
|
||||
/// is itself an alternate, the primary and the primary's other versions), deduplicated.
|
||||
/// </summary>
|
||||
/// <returns>This video and every alternate version of it.</returns>
|
||||
public IReadOnlyList<Video> GetAllVersions()
|
||||
{
|
||||
return GetAllItemsForMediaSources()
|
||||
.Select(i => i.Item)
|
||||
.OfType<Video>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the alternate version of this video that matches the supplied item id.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The version item id (the playback media source id).</param>
|
||||
/// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns>
|
||||
public Video GetAlternateVersion(Guid itemId)
|
||||
{
|
||||
return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId));
|
||||
}
|
||||
|
||||
public override string CreatePresentationUniqueKey()
|
||||
{
|
||||
if (PrimaryVersionId.HasValue)
|
||||
@@ -642,39 +738,58 @@ namespace MediaBrowser.Controller.Entities
|
||||
}).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ids of the items whose owned extras belong to this item.
|
||||
/// Extras are linked to a single version but need tp be surfaced for all versions.
|
||||
/// </summary>
|
||||
/// <returns>An array containing the owner ids.</returns>
|
||||
protected override Guid[] GetExtraOwnerIds()
|
||||
{
|
||||
return GetAllItemsForMediaSources()
|
||||
.Select(i => i.Item.Id)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
|
||||
{
|
||||
var list = new List<(BaseItem, MediaSourceType)>
|
||||
{
|
||||
(this, MediaSourceType.Default)
|
||||
};
|
||||
var primary = PrimaryVersionId.HasValue
|
||||
? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
|
||||
: null;
|
||||
|
||||
list.AddRange(
|
||||
LibraryManager.GetLinkedAlternateVersions(this)
|
||||
.Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
|
||||
var primaryLinked = primary is null
|
||||
? []
|
||||
: LibraryManager.GetLinkedAlternateVersions(primary).ToList();
|
||||
|
||||
if (PrimaryVersionId.HasValue)
|
||||
{
|
||||
if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
|
||||
{
|
||||
var existingIds = list.Select(i => i.Item1.Id).ToList();
|
||||
list.Add((primary, MediaSourceType.Grouping));
|
||||
list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
|
||||
}
|
||||
}
|
||||
// Grouping marks user-merged (splittable) sources. The primary is only such a source when
|
||||
// this video is linked onto it; for local (file-based) alternates the primary is just
|
||||
// another default source.
|
||||
var primaryType = primaryLinked.Any(i => i.Id.Equals(Id))
|
||||
? MediaSourceType.Grouping
|
||||
: MediaSourceType.Default;
|
||||
|
||||
var localAlternates = list
|
||||
.SelectMany(i =>
|
||||
{
|
||||
return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
|
||||
})
|
||||
.Select(LibraryManager.GetItemById)
|
||||
.Where(i => i is not null)
|
||||
// This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
|
||||
var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
|
||||
.Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
|
||||
.Concat(primary is null
|
||||
? []
|
||||
: primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType)))
|
||||
.ToList();
|
||||
|
||||
list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
|
||||
// The local (file-based) alternate versions of every grouped item.
|
||||
var localAlternates = grouped
|
||||
.Select(i => i.Item1)
|
||||
.OfType<Video>()
|
||||
.SelectMany(LibraryManager.GetLocalAlternateVersionIds)
|
||||
.Select(LibraryManager.GetItemById)
|
||||
.Where(i => i is not null)
|
||||
.Select(i => (i, MediaSourceType.Default));
|
||||
|
||||
return list;
|
||||
// Deduplicate
|
||||
return grouped
|
||||
.Concat(localAlternates)
|
||||
.DistinctBy(i => i.Item1.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user