mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
Fix multiple version resolution
This commit is contained in:
@@ -704,6 +704,57 @@ namespace Emby.Server.Implementations.Library
|
||||
public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null, CollectionType? collectionType = null)
|
||||
=> ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Video? ResolveAlternateVersion(string path, Type expectedVideoType, Folder? parent, CollectionType? collectionType)
|
||||
{
|
||||
// Clean up any existing item saved with wrong type (e.g. Video instead of Movie).
|
||||
// This happens when items were previously resolved without proper type context
|
||||
// in mixed-content libraries where collectionType is null.
|
||||
var expectedId = GetNewItemId(path, expectedVideoType);
|
||||
if (expectedVideoType != typeof(Video))
|
||||
{
|
||||
var wrongTypeId = GetNewItemId(path, typeof(Video));
|
||||
if (!wrongTypeId.Equals(expectedId) && GetItemById(wrongTypeId) is Video wrongTypeItem)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Removing alternate version with wrong type {WrongType}, expected {ExpectedType}: {Path}",
|
||||
wrongTypeItem.GetType().Name,
|
||||
expectedVideoType.Name,
|
||||
path);
|
||||
DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false });
|
||||
}
|
||||
}
|
||||
|
||||
var resolved = ResolvePath(
|
||||
_fileSystem.GetFileSystemInfo(path),
|
||||
parent,
|
||||
collectionType: collectionType) as Video;
|
||||
|
||||
if (resolved is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the alternate version has the same concrete type as the primary video.
|
||||
// ResolvePath may return a generic Video for files in mixed-content libraries
|
||||
// where collectionType is null, even though the primary is a Movie/Episode/etc.
|
||||
if (resolved.GetType() != expectedVideoType)
|
||||
{
|
||||
if (Activator.CreateInstance(expectedVideoType) is Video correctVideo)
|
||||
{
|
||||
correctVideo.Path = resolved.Path;
|
||||
correctVideo.Name = resolved.Name;
|
||||
correctVideo.VideoType = resolved.VideoType;
|
||||
correctVideo.ProductionYear = resolved.ProductionYear;
|
||||
correctVideo.ExtraType = resolved.ExtraType;
|
||||
resolved = correctVideo;
|
||||
}
|
||||
}
|
||||
|
||||
resolved.Id = expectedId;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private BaseItem? ResolvePath(
|
||||
FileSystemMetadata fileInfo,
|
||||
IDirectoryService directoryService,
|
||||
@@ -2125,14 +2176,8 @@ namespace Emby.Server.Implementations.Library
|
||||
if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId)))
|
||||
{
|
||||
// Alternate version doesn't exist, resolve and create it
|
||||
// Pass parent and collectionType so the resolver creates the correct type
|
||||
// (e.g. Movie instead of generic Video)
|
||||
var altVideo = ResolvePath(
|
||||
_fileSystem.GetFileSystemInfo(path),
|
||||
new DirectoryService(_fileSystem),
|
||||
null,
|
||||
parentFolder,
|
||||
parentCollectionType) as Video;
|
||||
// ensuring it has the same type as the primary video
|
||||
var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType);
|
||||
if (altVideo is not null)
|
||||
{
|
||||
altVideo.OwnerId = video.Id;
|
||||
@@ -2333,14 +2378,8 @@ namespace Emby.Server.Implementations.Library
|
||||
if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId)))
|
||||
{
|
||||
// Alternate version doesn't exist, resolve and create it
|
||||
// Pass parent and collectionType so the resolver creates the correct type
|
||||
// (e.g. Movie instead of generic Video)
|
||||
var altVideo = ResolvePath(
|
||||
_fileSystem.GetFileSystemInfo(path),
|
||||
new DirectoryService(_fileSystem),
|
||||
null,
|
||||
parentFolder,
|
||||
parentCollectionType) as Video;
|
||||
// ensuring it has the same type as the primary video
|
||||
var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType);
|
||||
if (altVideo is not null)
|
||||
{
|
||||
altVideo.OwnerId = video.Id;
|
||||
@@ -2353,6 +2392,14 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
_itemRepository.SaveItems(allItems, cancellationToken);
|
||||
|
||||
foreach (var item in allItems)
|
||||
{
|
||||
if (!items.Contains(item))
|
||||
{
|
||||
RegisterItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (parent is Folder folder)
|
||||
{
|
||||
folder.Children = null;
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.",
|
||||
"TaskKeyframeExtractor": "Keyframe Extractor",
|
||||
"TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.",
|
||||
"TaskExtractMediaSegments": "Media Segment Scan",
|
||||
"TaskExtractMediaSegments": "Media Segment Scan",
|
||||
"TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
|
||||
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
|
||||
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
|
||||
|
||||
@@ -304,7 +304,7 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
dbQuery = ApplyNavigations(dbQuery, filter).AsSplitQuery();
|
||||
|
||||
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
@@ -1850,7 +1850,8 @@ public sealed class BaseItemRepository
|
||||
.Include(e => e.LockedFields)
|
||||
.Include(e => e.UserData)
|
||||
.Include(e => e.Images)
|
||||
.Include(e => e.LinkedChildEntities);
|
||||
.Include(e => e.LinkedChildEntities)
|
||||
.AsSingleQuery();
|
||||
|
||||
var item = dbQuery.FirstOrDefault(e => e.Id == id);
|
||||
if (item is null)
|
||||
|
||||
@@ -85,58 +85,6 @@ namespace MediaBrowser.Controller.Entities.Movies
|
||||
return info;
|
||||
}
|
||||
|
||||
protected override async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var newOptions = new MetadataRefreshOptions(options)
|
||||
{
|
||||
SearchResult = null
|
||||
};
|
||||
|
||||
var id = LibraryManager.GetNewItemId(path, typeof(Movie));
|
||||
|
||||
// Check if the file still exists
|
||||
if (!FileSystem.FileExists(path))
|
||||
{
|
||||
// File was removed - clean up any orphaned database entry
|
||||
if (LibraryManager.GetItemById(id) is Movie orphanedMovie && orphanedMovie.OwnerId.Equals(Id))
|
||||
{
|
||||
Logger.LogInformation("Alternate version file no longer exists, removing orphaned item: {Path}", path);
|
||||
LibraryManager.DeleteItem(orphanedMovie, new DeleteOptions { DeleteFileLocation = false });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (LibraryManager.GetItemById(id) is not Movie movie)
|
||||
{
|
||||
// Pass parent and collectionType so the resolver creates a Movie
|
||||
// instead of a generic Video
|
||||
var parentFolder = GetParent() as Folder;
|
||||
var collectionType = GetParents().OfType<ICollectionFolder>().FirstOrDefault()?.CollectionType;
|
||||
movie = LibraryManager.ResolvePath(
|
||||
FileSystem.GetFileSystemInfo(path),
|
||||
parentFolder,
|
||||
collectionType: collectionType) as Movie;
|
||||
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
|
||||
if (movie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (movie.OwnerId.Equals(Guid.Empty))
|
||||
{
|
||||
movie.OwnerId = Id;
|
||||
}
|
||||
|
||||
await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create LinkedChild entry for this local alternate version
|
||||
LibraryManager.UpsertLinkedChild(Id, movie.Id, LinkedChildType.LocalAlternateVersion);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool BeforeMetadataRefresh(bool replaceAllMetadata)
|
||||
{
|
||||
|
||||
@@ -453,38 +453,45 @@ namespace MediaBrowser.Controller.Entities
|
||||
// The additional parts won't have additional parts themselves
|
||||
if (IsFileProtocol && SupportsOwnedItems)
|
||||
{
|
||||
if (!IsStacked)
|
||||
// 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)
|
||||
{
|
||||
RefreshLinkedAlternateVersions();
|
||||
|
||||
if (LocalAlternateVersions.Length > 0)
|
||||
{
|
||||
// Check if LinkedChildren are in sync before processing
|
||||
var existingLinkCount = LibraryManager.GetLocalAlternateVersionIds(this).Count();
|
||||
var tasks = LocalAlternateVersions
|
||||
.Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken));
|
||||
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
if (existingLinkCount != LocalAlternateVersions.Length)
|
||||
{
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges;
|
||||
}
|
||||
|
||||
protected virtual async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken)
|
||||
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;
|
||||
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
|
||||
var id = LibraryManager.GetNewItemId(path, GetType());
|
||||
if (LibraryManager.GetItemById(id) is Video video)
|
||||
{
|
||||
LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion);
|
||||
@@ -516,18 +523,21 @@ namespace MediaBrowser.Controller.Entities
|
||||
if (LibraryManager.GetItemById(id) is not Video video)
|
||||
{
|
||||
var parentFolder = GetParent() as Folder;
|
||||
var collectionType = GetParents().OfType<ICollectionFolder>().FirstOrDefault()?.CollectionType;
|
||||
var collectionType = LibraryManager.GetContentType(this);
|
||||
video = LibraryManager.ResolvePath(
|
||||
FileSystem.GetFileSystemInfo(path),
|
||||
parentFolder,
|
||||
collectionType: collectionType) as Video;
|
||||
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
if (video is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (video is null)
|
||||
{
|
||||
return;
|
||||
video.Id = id;
|
||||
video.OwnerId = Id;
|
||||
LibraryManager.CreateItem(video, parentFolder);
|
||||
newOptions.ForceSave = true;
|
||||
}
|
||||
|
||||
if (video.OwnerId.IsEmpty())
|
||||
@@ -538,18 +548,6 @@ namespace MediaBrowser.Controller.Entities
|
||||
await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void RefreshLinkedAlternateVersions()
|
||||
{
|
||||
foreach (var child in LinkedAlternateVersions)
|
||||
{
|
||||
// Reset the cached value
|
||||
if (child.ItemId.IsNullOrEmpty())
|
||||
{
|
||||
child.ItemId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,22 @@ namespace MediaBrowser.Controller.Library
|
||||
IDirectoryService? directoryService = null,
|
||||
CollectionType? collectionType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a video file as an alternate version of a primary video, ensuring the result
|
||||
/// has the same concrete type as the primary (e.g. Movie instead of generic Video).
|
||||
/// Also cleans up any existing item with the wrong type from a previous scan.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path of the alternate version.</param>
|
||||
/// <param name="expectedVideoType">The expected concrete type (same as the primary video).</param>
|
||||
/// <param name="parent">The parent folder.</param>
|
||||
/// <param name="collectionType">The collection type of the library.</param>
|
||||
/// <returns>A correctly-typed Video, or null if resolution fails.</returns>
|
||||
Video? ResolveAlternateVersion(
|
||||
string path,
|
||||
Type expectedVideoType,
|
||||
Folder? parent,
|
||||
CollectionType? collectionType);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a set of files into a list of BaseItem.
|
||||
/// </summary>
|
||||
|
||||
@@ -60,11 +60,13 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
|
||||
|
||||
var customOptions = databaseConfiguration.CustomProviderOptions?.Options;
|
||||
|
||||
var sqliteConnectionBuilder = new SqliteConnectionStringBuilder();
|
||||
sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
|
||||
sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default);
|
||||
sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true);
|
||||
sqliteConnectionBuilder.DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30);
|
||||
var sqliteConnectionBuilder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"),
|
||||
Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default),
|
||||
Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true),
|
||||
DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 60)
|
||||
};
|
||||
|
||||
var connectionString = sqliteConnectionBuilder.ToString();
|
||||
|
||||
@@ -156,7 +158,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
|
||||
/// <inheritdoc />
|
||||
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
// ensure there are absolutly no dangling Sqlite connections.
|
||||
// ensure there are absolutely no dangling Sqlite connections.
|
||||
SqliteConnection.ClearAllPools();
|
||||
var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
|
||||
var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db");
|
||||
|
||||
Reference in New Issue
Block a user