Files
jellyfin/MediaBrowser.Controller/Entities/Video.cs
2026-06-03 16:56:09 +02:00

772 lines
27 KiB
C#

#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities
{
/// <summary>
/// Class Video.
/// </summary>
public class Video : BaseItem,
IHasAspectRatio,
ISupportsPlaceHolders,
IHasMediaSources
{
public Video()
{
AdditionalParts = [];
LocalAlternateVersions = [];
SubtitleFiles = [];
AudioFiles = [];
LinkedAlternateVersions = [];
}
[JsonIgnore]
public Guid? PrimaryVersionId { get; set; }
public string[] AdditionalParts { get; set; }
public string[] LocalAlternateVersions { get; set; }
public LinkedChild[] LinkedAlternateVersions { get; set; }
[JsonIgnore]
public override bool SupportsPlayedStatus => true;
[JsonIgnore]
public override bool SupportsPeople => true;
[JsonIgnore]
public override bool SupportsInheritedParentImages => true;
[JsonIgnore]
public override bool SupportsPositionTicksResume
{
get
{
var extraType = ExtraType;
if (extraType.HasValue)
{
if (extraType.Value == Model.Entities.ExtraType.Sample)
{
return false;
}
if (extraType.Value == Model.Entities.ExtraType.ThemeVideo)
{
return false;
}
if (extraType.Value == Model.Entities.ExtraType.Trailer)
{
return false;
}
}
return true;
}
}
[JsonIgnore]
public override bool SupportsThemeMedia => true;
/// <summary>
/// Gets or sets the timestamp.
/// </summary>
/// <value>The timestamp.</value>
public TransportStreamTimestamp? Timestamp { get; set; }
/// <summary>
/// Gets or sets the subtitle paths.
/// </summary>
/// <value>The subtitle paths.</value>
public string[] SubtitleFiles { get; set; }
/// <summary>
/// Gets or sets the audio paths.
/// </summary>
/// <value>The audio paths.</value>
public string[] AudioFiles { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance has subtitles.
/// </summary>
/// <value><c>true</c> if this instance has subtitles; otherwise, <c>false</c>.</value>
public bool HasSubtitles { get; set; }
public bool IsPlaceHolder { get; set; }
/// <summary>
/// Gets or sets the default index of the video stream.
/// </summary>
/// <value>The default index of the video stream.</value>
public int? DefaultVideoStreamIndex { get; set; }
/// <summary>
/// Gets or sets the type of the video.
/// </summary>
/// <value>The type of the video.</value>
public VideoType VideoType { get; set; }
/// <summary>
/// Gets or sets the type of the iso.
/// </summary>
/// <value>The type of the iso.</value>
public IsoType? IsoType { get; set; }
/// <summary>
/// Gets or sets the video3 D format.
/// </summary>
/// <value>The video3 D format.</value>
public Video3DFormat? Video3DFormat { get; set; }
/// <summary>
/// Gets or sets the aspect ratio.
/// </summary>
/// <value>The aspect ratio.</value>
public string AspectRatio { get; set; }
[JsonIgnore]
public override bool SupportsAddingToPlaylist => true;
[JsonIgnore]
public int MediaSourceCount
{
get
{
return GetMediaSourceCount();
}
}
[JsonIgnore]
public bool IsStacked => AdditionalParts.Length > 0;
[JsonIgnore]
public override bool HasLocalAlternateVersions => LibraryManager.GetLocalAlternateVersionIds(this).Any();
public static IRecordingsManager RecordingsManager { get; set; }
[JsonIgnore]
public override SourceType SourceType
{
get
{
if (IsActiveRecording())
{
return SourceType.LiveTV;
}
return base.SourceType;
}
}
[JsonIgnore]
public bool IsCompleteMedia
{
get
{
if (SourceType == SourceType.Channel)
{
return !Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase);
}
return !IsActiveRecording();
}
}
[JsonIgnore]
protected virtual bool EnableDefaultVideoUserDataKeys => true;
[JsonIgnore]
public override string ContainingFolderPath
{
get
{
if (IsStacked)
{
return System.IO.Path.GetDirectoryName(Path);
}
if (!IsPlaceHolder)
{
if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd)
{
return Path;
}
}
return base.ContainingFolderPath;
}
}
[JsonIgnore]
public override string FileNameWithoutExtension
{
get
{
if (IsFileProtocol)
{
if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd)
{
return System.IO.Path.GetFileName(Path);
}
return System.IO.Path.GetFileNameWithoutExtension(Path);
}
return null;
}
}
/// <summary>
/// Gets a value indicating whether [is3 D].
/// </summary>
/// <value><c>true</c> if [is3 D]; otherwise, <c>false</c>.</value>
[JsonIgnore]
public bool Is3D => Video3DFormat.HasValue;
/// <summary>
/// Gets the type of the media.
/// </summary>
/// <value>The type of the media.</value>
[JsonIgnore]
public override MediaType MediaType => MediaType.Video;
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
{
callstack ??= new();
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
if (item is Video video)
{
if (callstack.Contains(video.Id))
{
// Count alternate versions using LibraryManager
var linkedCount = LibraryManager.GetLinkedAlternateVersions(video).Count();
var localCount = LibraryManager.GetLocalAlternateVersionIds(video).Count();
return linkedCount + localCount + 1;
}
callstack.Add(video.Id);
return video.GetMediaSourceCount(callstack);
}
}
// Count alternate versions using LibraryManager
var linkedVersionCount = LibraryManager.GetLinkedAlternateVersions(this).Count();
var localVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
return linkedVersionCount + localVersionCount + 1;
}
/// <inheritdoc />
public override string GetInheritedOriginalLanguage()
{
if (ExtraType.GetValueOrDefault() == Model.Entities.ExtraType.Trailer)
{
return GetOwner()?.GetInheritedOriginalLanguage();
}
return OriginalLanguage ?? GetOwner()?.GetInheritedOriginalLanguage();
}
public override List<string> GetUserDataKeys()
{
var list = base.GetUserDataKeys();
if (EnableDefaultVideoUserDataKeys)
{
if (ExtraType.HasValue)
{
var key = this.GetProviderId(MetadataProvider.Tmdb);
if (!string.IsNullOrEmpty(key))
{
list.Insert(0, GetUserDataKey(key));
}
key = this.GetProviderId(MetadataProvider.Imdb);
if (!string.IsNullOrEmpty(key))
{
list.Insert(0, GetUserDataKey(key));
}
}
else
{
var key = this.GetProviderId(MetadataProvider.Imdb);
if (!string.IsNullOrEmpty(key))
{
list.Insert(0, key);
}
key = this.GetProviderId(MetadataProvider.Tmdb);
if (!string.IsNullOrEmpty(key))
{
list.Insert(0, key);
}
}
}
return list;
}
public void SetPrimaryVersionId(Guid? id)
{
PrimaryVersionId = id;
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)
{
return PrimaryVersionId.Value.ToString("N", CultureInfo.InvariantCulture);
}
return base.CreatePresentationUniqueKey();
}
public override bool CanDownload()
{
if (VideoType == VideoType.Dvd || VideoType == VideoType.BluRay)
{
return false;
}
return IsFileProtocol;
}
protected override bool IsActiveRecording()
{
return RecordingsManager.GetActiveRecordingInfo(Path) is not null;
}
public override bool CanDelete()
{
if (IsActiveRecording())
{
return false;
}
return base.CanDelete();
}
public IEnumerable<Guid> GetAdditionalPartIds()
{
return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video)));
}
private string GetUserDataKey(string providerId)
{
var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant();
// Make sure different trailers have their own data.
if (RunTimeTicks.HasValue)
{
key += "-" + RunTimeTicks.Value.ToString(CultureInfo.InvariantCulture);
}
return key;
}
/// <summary>
/// Gets the additional parts.
/// </summary>
/// <returns>IEnumerable{Video}.</returns>
public IOrderedEnumerable<Video> GetAdditionalParts()
{
return GetAdditionalPartIds()
.Select(i => LibraryManager.GetItemById(i))
.Where(i => i is not null)
.OfType<Video>()
.OrderBy(i => i.SortName);
}
internal override ItemUpdateType UpdateFromResolvedItem(BaseItem newItem)
{
var updateType = base.UpdateFromResolvedItem(newItem);
if (newItem is Video newVideo)
{
if (!AdditionalParts.SequenceEqual(newVideo.AdditionalParts, StringComparer.Ordinal))
{
AdditionalParts = newVideo.AdditionalParts;
updateType |= ItemUpdateType.MetadataImport;
}
if (!LocalAlternateVersions.SequenceEqual(newVideo.LocalAlternateVersions, StringComparer.Ordinal))
{
LocalAlternateVersions = newVideo.LocalAlternateVersions;
updateType |= ItemUpdateType.MetadataImport;
}
if (VideoType != newVideo.VideoType)
{
VideoType = newVideo.VideoType;
updateType |= ItemUpdateType.MetadataImport;
}
}
return updateType;
}
protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
// Clean up LocalAlternateVersions - remove paths that no longer exist
if (LocalAlternateVersions.Length > 0)
{
var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray();
if (validPaths.Length != LocalAlternateVersions.Length)
{
LocalAlternateVersions = validPaths;
hasChanges = true;
}
}
if (IsStacked)
{
var tasks = AdditionalParts
.Select(i => RefreshMetadataForOwnedVideo(options, true, i, typeof(Video), cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
}
// Must have a parent to have additional parts or alternate versions
// In other words, it must be part of the Parent/Child tree
// The additional parts won't have additional parts themselves
if (IsFileProtocol && SupportsOwnedItems)
{
// Check if LinkedChildren are in sync before processing
var existingVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
var tasks = LocalAlternateVersions
.Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
if (existingVersionCount != LocalAlternateVersions.Length)
{
hasChanges = true;
}
}
return hasChanges;
}
private async Task RefreshMetadataForVersions(
MetadataRefreshOptions options,
bool copyTitleMetadata,
string path,
CancellationToken cancellationToken)
{
// Ensure the alternate version exists with the correct type (e.g. Movie, not Video)
// before refreshing. This must happen here rather than in RefreshMetadataForOwnedVideo
// because that method is also used for stacked parts which should keep their resolved type.
var id = LibraryManager.GetNewItemId(path, GetType());
if (LibraryManager.GetItemById(id) is not Video && FileSystem.FileExists(path))
{
var parentFolder = GetParent() as Folder;
var collectionType = LibraryManager.GetContentType(this);
var altVideo = LibraryManager.ResolveAlternateVersion(path, GetType(), parentFolder, collectionType);
if (altVideo is not null)
{
altVideo.OwnerId = Id;
altVideo.SetPrimaryVersionId(Id);
LibraryManager.CreateItem(altVideo, GetParent());
}
}
await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false);
// Create LinkedChild entry for this local alternate version
// This ensures the relationship exists in the database even if the alternate version
// was created after the primary video was first saved
if (LibraryManager.GetItemById(id) is Video video)
{
LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion);
// Ensure PrimaryVersionId is set for existing alternate versions that may not have it
if (!video.PrimaryVersionId.HasValue)
{
video.SetPrimaryVersionId(Id);
await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
}
private new Task RefreshMetadataForOwnedVideo(
MetadataRefreshOptions options,
bool copyTitleMetadata,
string path,
CancellationToken cancellationToken)
=> RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, GetType(), cancellationToken);
private async Task RefreshMetadataForOwnedVideo(
MetadataRefreshOptions options,
bool copyTitleMetadata,
string path,
Type itemType,
CancellationToken cancellationToken)
{
var newOptions = new MetadataRefreshOptions(options)
{
SearchResult = null
};
var id = LibraryManager.GetNewItemId(path, itemType);
// Check if the file still exists
if (!FileSystem.FileExists(path))
{
// File was removed - clean up any orphaned database entry
if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id))
{
Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path);
LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false });
}
return;
}
if (LibraryManager.GetItemById(id) is not Video video)
{
var parentFolder = GetParent() as Folder;
var collectionType = LibraryManager.GetContentType(this);
video = LibraryManager.ResolvePath(
FileSystem.GetFileSystemInfo(path),
parentFolder,
collectionType: collectionType) as Video;
if (video is null)
{
return;
}
// Ensure parts use the expected base type (e.g. Video, not Movie)
if (video.GetType() != itemType && Activator.CreateInstance(itemType) is Video correctVideo)
{
correctVideo.Path = video.Path;
correctVideo.Name = video.Name;
correctVideo.VideoType = video.VideoType;
correctVideo.ProductionYear = video.ProductionYear;
correctVideo.ExtraType = video.ExtraType;
video = correctVideo;
}
video.Id = id;
video.OwnerId = Id;
LibraryManager.CreateItem(video, parentFolder);
newOptions.ForceSave = true;
}
if (video.OwnerId.IsEmpty())
{
video.OwnerId = Id;
}
await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public override async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
{
await base.UpdateToRepositoryAsync(updateReason, cancellationToken).ConfigureAwait(false);
var localAlternates = LibraryManager.GetLocalAlternateVersionIds(this)
.Select(i => LibraryManager.GetItemById(i))
.Where(i => i is not null);
foreach (var item in localAlternates)
{
item.ImageInfos = ImageInfos;
item.Overview = Overview;
item.ProductionYear = ProductionYear;
item.PremiereDate = PremiereDate;
item.CommunityRating = CommunityRating;
item.OfficialRating = OfficialRating;
item.Genres = Genres;
item.ProviderIds = ProviderIds;
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataDownload, cancellationToken).ConfigureAwait(false);
}
}
public override IEnumerable<FileSystemMetadata> GetDeletePaths()
{
if (!IsInMixedFolder)
{
return new[]
{
new FileSystemMetadata
{
FullName = ContainingFolderPath,
IsDirectory = true
}
};
}
return base.GetDeletePaths();
}
public virtual MediaStream GetDefaultVideoStream()
{
if (!DefaultVideoStreamIndex.HasValue)
{
return null;
}
return MediaSourceManager.GetMediaStreams(new MediaStreamQuery
{
ItemId = Id,
Index = DefaultVideoStreamIndex.Value
}).FirstOrDefault();
}
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
var primary = PrimaryVersionId.HasValue
? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
: 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
? []
: LibraryManager.GetLinkedAlternateVersions(primary).Prepend(primary).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
.ToList();
// 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));
// Deduplicate
return grouped
.Concat(localAlternates)
.DistinctBy(i => i.Item1.Id)
.ToList();
}
}
}