Fix playlist entries being lost on migration and library scans

This commit is contained in:
Shadowghost
2026-07-29 14:40:49 +02:00
parent dc300fae53
commit 8293eb26b9
6 changed files with 426 additions and 104 deletions
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Library;
@@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers
};
}
// Anything directly inside the internal playlists folder is a playlist, even when its
// playlist.xml is missing: failing to resolve here makes the library scan treat the
// playlist as deleted from disk and remove it, taking its items with it.
if (args.Parent is PlaylistsFolder)
{
return new Playlist
{
Path = args.Path,
Name = filename,
OpenAccess = true
};
}
// It's a directory-based playlist if the directory contains a playlist file
IEnumerable<string> filePaths;
try
@@ -183,7 +183,7 @@ public static class BaseItemMapper
if (dto is Folder folder)
{
folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0)
if (entity.LinkedChildEntities is not null)
{
folder.LinkedChildren = entity.LinkedChildEntities
.OrderBy(e => e.SortOrder)
@@ -428,23 +428,60 @@ public class ItemPersistenceService : IItemPersistenceService
foreach (var item in tuples)
{
// A container that was never hydrated cannot be used to rewrite its links: its empty
// array means "unknown", so clearing the stored rows would silently empty the item.
if (item.Item is Folder { LinkedChildrenLoaded: false })
{
continue;
}
if (item.Item is Folder or Video
&& allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
&& existingLinks.Count > 0)
{
context.LinkedChildren.RemoveRange(existingLinks);
// A video only owns its alternate version links; any other link on that parent is
// written by the folder branch below and must survive.
var staleLinks = item.Item is Folder
? existingLinks
: existingLinks
.Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion)
.ToList();
if (staleLinks.Count > 0)
{
context.LinkedChildren.RemoveRange(staleLinks);
}
}
}
context.SaveChanges();
// A LinkedChild's ItemId is only a cache.
var cachedChildIds = tuples
.Select(t => t.Item)
.OfType<Folder>()
.Where(f => f.LinkedChildrenLoaded)
.SelectMany(f => f.LinkedChildren)
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
.Select(lc => lc.ItemId!.Value)
.Distinct()
.ToList();
var knownChildIds = cachedChildIds.Count > 0
? context.BaseItems
.WhereOneOrMany(cachedChildIds, e => e.Id)
.Select(e => e.Id)
.ToHashSet()
: [];
foreach (var item in tuples)
{
if (item.Item is Folder folder && folder.LinkedChildren.Length > 0)
if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0)
{
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
var pathsToResolve = folder.LinkedChildren
.Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path))
.Where(lc => !string.IsNullOrEmpty(lc.Path)
&& (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)))
.Select(lc => lc.Path)
.Distinct()
.ToList();
@@ -461,12 +498,16 @@ public class ItemPersistenceService : IItemPersistenceService
foreach (var linkedChild in folder.LinkedChildren)
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty())
if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value))
{
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
childItemId = resolvedId;
}
else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty())
{
childItemId = libraryItemId;
}
}
#pragma warning restore CS0618
@@ -500,11 +541,14 @@ public class ItemPersistenceService : IItemPersistenceService
{
if (!existingChildIds.Contains(childId))
{
#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics
_logger.LogWarning(
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database",
item.Item.Name,
item.Item.Id,
childId);
childId,
linkedChild.Path ?? "unknown");
#pragma warning restore CS0618
continue;
}
@@ -63,7 +63,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var itemsWithData = context.BaseItems
.Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
.Select(b => new { b.Id, b.Data, b.Type })
.Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder })
.ToList();
_logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count);
@@ -74,6 +74,15 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.GroupBy(b => b.Path!)
.ToDictionary(g => g.Key, g => g.First().Id);
// Needed to tell a stale cached ItemId apart from one that still points at a real item.
var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet();
var playlistParentIds = itemsWithData
.Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist")
.Select(b => b.Id)
.ToHashSet();
var droppedChildren = 0;
var linkedChildrenToAdd = new List<LinkedChildEntity>();
var processedCount = 0;
const int progressLogStep = 1000;
@@ -100,7 +109,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
// Handle Video alternate versions
if (isVideo)
{
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd);
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd);
}
// Handle LinkedChildren (for containers and other items)
@@ -110,45 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
continue;
}
// Legacy entries may hold a path relative to the container that holds them, so the
// container's own location has to be a real path, not a virtual one.
var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path);
var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath);
var sortOrder = 0;
foreach (var childElement in linkedChildrenElement.EnumerateArray())
{
Guid? childId = null;
if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
{
var itemIdStr = itemIdProp.GetString();
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
{
childId = parsedId;
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (childElement.TryGetProperty("Path", out var pathProp))
{
var path = pathProp.GetString();
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
{
childId = resolvedId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (childElement.TryGetProperty("LibraryItemId", out var libIdProp))
{
var libIdStr = libIdProp.GetString();
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
{
childId = parsedLibId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds);
if (!childId.HasValue)
{
droppedChildren++;
_logger.LogWarning(
"Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}",
item.Id,
GetStringProperty(childElement, "ItemId") ?? "none",
GetStringProperty(childElement, "Path") ?? "none");
continue;
}
@@ -196,23 +182,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.Select(lc => new { lc.ParentId, lc.ChildId })
.ToHashSet();
// A playlist may list the same child more than once, so it cannot be keyed by
// (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which
// keeps the routine re-runnable without collapsing repeated entries.
var populatedParentIds = context.LinkedChildren
.Select(lc => lc.ParentId)
.Distinct()
.ToHashSet();
var toInsert = linkedChildrenToAdd
.Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
.Where(lc => playlistParentIds.Contains(lc.ParentId)
? !populatedParentIds.Contains(lc.ParentId)
: !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
.ToList();
if (toInsert.Count > 0)
{
// Deduplicate by composite key (ParentId, ChildId)
// Every container type other than a playlist keeps a single entry per child.
// Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
toInsert = toInsert
.OrderBy(lc => lc.ChildType switch
{
LinkedChildType.LocalAlternateVersion => 0,
LinkedChildType.LinkedAlternateVersion => 1,
_ => 2
})
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
.ToList();
toInsert =
[
.. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)),
.. toInsert
.Where(lc => !playlistParentIds.Contains(lc.ParentId))
.OrderBy(lc => lc.ChildType switch
{
LinkedChildType.LocalAlternateVersion => 0,
LinkedChildType.LinkedAlternateVersion => 1,
_ => 2
})
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
];
var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
var existingChildIds = context.BaseItems
@@ -266,7 +266,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("No LinkedChildren data found to migrate.");
}
_logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
_logger.LogInformation(
"LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.",
processedCount,
droppedChildren);
CleanupWrongTypeAlternateVersions(context);
CleanupOrphanedAlternateVersionBaseItems(context);
@@ -417,6 +420,12 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var internalMetadataPath = _appPaths.InternalMetadataPath;
// An item outside every library location is normally left over from a removed media path, but
// it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the
// first container start, for example). Only act on it while every location is readable.
var canRemoveUnrootedItems = inaccessiblePaths.Count == 0;
var skippedUnrootedItems = 0;
var staleIds = new List<Guid>();
foreach (var item in itemsWithPaths)
{
@@ -435,6 +444,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
// Directory check covers BDMV/DVD items whose Path points to a folder
if (!File.Exists(path) && !Directory.Exists(path))
{
_logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path);
staleIds.Add(item.Id);
}
}
@@ -442,12 +452,28 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
{
// Item is not under ANY library location (accessible or not) —
// it's orphaned from all libraries (e.g. media path was removed from config)
staleIds.Add(item.Id);
if (canRemoveUnrootedItems)
{
_logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path);
staleIds.Add(item.Id);
}
else
{
skippedUnrootedItems++;
}
}
// Otherwise: item is under an inaccessible location — skip (storage may be offline)
}
if (skippedUnrootedItems > 0)
{
_logger.LogWarning(
"Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.",
skippedUnrootedItems,
inaccessiblePaths.Count);
}
if (staleIds.Count == 0)
{
_logger.LogInformation("No stale items found.");
@@ -517,18 +543,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
orphanedLinkedChildren.AddRange(orphanedByParent);
}
// Remove all orphaned records
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList();
// Remove all orphaned records. Both queries can return the same row, and a playlist may hold
// several rows for one child, so the position is what identifies an entry here.
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList();
context.LinkedChildren.RemoveRange(distinctOrphaned);
context.SaveChanges();
_logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
}
/// <summary>
/// Resolves the item a legacy LinkedChild entry points at.
/// </summary>
private static Guid? ResolveChildId(
JsonElement childElement,
string? containingFolderPath,
Dictionary<string, Guid> pathToIdMap,
HashSet<Guid> allItemIds)
{
// Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value
// went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore
// fall through to the path, or the entry is lost even though its file is still in the library.
if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId))
{
return itemId;
}
var path = GetStringProperty(childElement, "Path");
if (!string.IsNullOrEmpty(path))
{
if (pathToIdMap.TryGetValue(path, out var idByPath))
{
return idByPath;
}
// 10.x resolved entries relative to the container that holds them.
if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath))
{
string? absolutePath = null;
try
{
absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path));
}
catch (ArgumentException)
{
// Malformed path, nothing to resolve.
}
if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath))
{
return idByAbsolutePath;
}
}
}
if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId))
{
return libraryItemId;
}
return null;
}
private static string? GetStringProperty(JsonElement element, string propertyName)
=> element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value)
{
value = Guid.Empty;
var raw = GetStringProperty(element, propertyName);
return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty();
}
private void ProcessVideoAlternateVersions(
JsonElement root,
Guid parentId,
Dictionary<string, Guid> pathToIdMap,
HashSet<Guid> allItemIds,
List<LinkedChildEntity> linkedChildrenToAdd)
{
int sortOrder = 0;
@@ -581,45 +675,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
{
foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
{
Guid? childId = null;
// Try to get ItemId
if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
{
var itemIdStr = itemIdProp.GetString();
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
{
childId = parsedId;
}
}
// Try to get from Path if ItemId not available
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (linkedChildElement.TryGetProperty("Path", out var pathProp))
{
var path = pathProp.GetString();
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
{
childId = resolvedId;
}
}
}
// Try LibraryItemId as fallback
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp))
{
var libIdStr = libIdProp.GetString();
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
{
childId = parsedLibId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds);
if (!childId.HasValue)
{
_logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId);
continue;
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Restores playlist entries from playlist.xml for playlists that lost all of their children.
/// </summary>
[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))]
internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine
{
private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist";
private const string PlaylistFileName = "playlist.xml";
private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IServerApplicationHost _appHost;
public RestorePlaylistChildrenFromMetadata(
ILoggerFactory loggerFactory,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost appHost)
{
_logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>();
_dbProvider = dbProvider;
_appHost = appHost;
}
/// <inheritdoc/>
public void Perform()
{
using var context = _dbProvider.CreateDbContext();
var playlists = context.BaseItems
.Where(b => b.Type == PlaylistTypeName && b.Path != null)
.Select(b => new { b.Id, b.Name, b.Path })
.ToList();
if (playlists.Count == 0)
{
return;
}
var childCountByPlaylist = context.LinkedChildren
.Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName))
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(g => g.ParentId, g => g.Count);
var pathToIdMap = context.BaseItems
.Where(b => b.Path != null)
.Select(b => new { b.Id, b.Path })
.GroupBy(b => b.Path!)
.ToDictionary(g => g.Key, g => g.First().Id);
var restoredPlaylists = 0;
var restoredEntries = 0;
foreach (var playlist in playlists)
{
// Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml.
// A playlist that is itself a file (.m3u and friends) is re-read by the library scan.
var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!);
var metadataPath = Path.Combine(playlistPath, PlaylistFileName);
if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath))
{
continue;
}
var storedPaths = ReadEntryPaths(metadataPath, playlist.Id);
if (storedPaths.Count == 0)
{
continue;
}
var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id);
if (childCount > 0)
{
// Merging into a playlist that still has entries would resurrect anything the user
// removed while the metadata file was not rewritten, and there is no way to tell the
// two apart. Report the mismatch instead so it can be checked by hand.
if (storedPaths.Count > childCount)
{
_logger.LogWarning(
"Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.",
playlist.Name,
playlist.Id,
childCount,
metadataPath,
storedPaths.Count);
}
continue;
}
var sortOrder = 0;
foreach (var storedPath in storedPaths)
{
if (!pathToIdMap.TryGetValue(storedPath, out var childId))
{
_logger.LogWarning(
"Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.",
storedPath,
playlist.Name);
continue;
}
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = playlist.Id,
ChildId = childId,
ChildType = LinkedChildType.Manual,
SortOrder = sortOrder
});
sortOrder++;
}
if (sortOrder > 0)
{
restoredPlaylists++;
restoredEntries += sortOrder;
_logger.LogInformation(
"Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.",
sortOrder,
playlist.Name,
playlist.Id,
metadataPath);
}
}
if (restoredEntries > 0)
{
context.SaveChanges();
_logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists);
}
}
private List<string> ReadEntryPaths(string metadataPath, Guid playlistId)
{
var paths = new List<string>();
var settings = new XmlReaderSettings
{
IgnoreComments = true,
IgnoreWhitespace = true,
IgnoreProcessingInstructions = true,
DtdProcessing = DtdProcessing.Prohibit
};
try
{
using var reader = XmlReader.Create(metadataPath, settings);
var inEntry = false;
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal))
{
inEntry = true;
}
else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal))
{
inEntry = false;
var value = reader.ReadElementContentAsString();
if (!string.IsNullOrWhiteSpace(value))
{
paths.Add(value.Trim());
}
}
}
}
catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId);
}
return paths;
}
}
+22 -6
View File
@@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities
public class Folder : BaseItem
{
private IEnumerable<BaseItem> _children;
public Folder()
{
LinkedChildren = Array.Empty<LinkedChild>();
}
private LinkedChild[] _linkedChildren = [];
public static IUserViewManager UserViewManager { get; set; }
@@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities
/// Gets or sets the linked children.
/// </summary>
[JsonIgnore]
public LinkedChild[] LinkedChildren { get; set; }
public LinkedChild[] LinkedChildren
{
get => _linkedChildren;
set
{
_linkedChildren = value;
// Assigning the collection means the caller knows the complete set of links.
LinkedChildrenLoaded = true;
}
}
/// <summary>
/// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links.
/// </summary>
/// <remarks>
/// An unloaded instance carries an empty array that means "unknown", not "no children" —
/// persisting it would delete every link the item has.
/// </remarks>
[JsonIgnore]
public bool LinkedChildrenLoaded { get; private set; }
[JsonIgnore]
public DateTime? DateLastMediaAdded { get; set; }