aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-08-01 08:06:02 -0400
committerGitHub <noreply@github.com>2026-08-01 08:06:02 -0400
commite816870f679f74164ecedccd98b8d8244ffb9593 (patch)
tree6b5f581d8e9ec6c6929abe4384bbe32771b75e4a /Jellyfin.Server
parentb2ae39e0d911dd8065c4504a82b00965a18c6bfd (diff)
parent8293eb26b995a21f88bff60dcf93da8d98080cc0 (diff)
Merge pull request #17416 from Shadowghost/enable-duplicate-playlist-children
Allow duplicate LinkedChildren for Playlists
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs61
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs242
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs191
3 files changed, 340 insertions, 154 deletions
diff --git a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs
deleted file mode 100644
index 1545ebdc8e..0000000000
--- a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading;
-using Jellyfin.Data.Enums;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Playlists;
-
-namespace Jellyfin.Server.Migrations.Routines;
-
-/// <summary>
-/// Remove duplicate playlist entries.
-/// </summary>
-#pragma warning disable CS0618 // Type or member is obsolete
-[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")]
-internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
-#pragma warning restore CS0618 // Type or member is obsolete
-{
- private readonly ILibraryManager _libraryManager;
- private readonly IPlaylistManager _playlistManager;
-
- public RemoveDuplicatePlaylistChildren(
- ILibraryManager libraryManager,
- IPlaylistManager playlistManager)
- {
- _libraryManager = libraryManager;
- _playlistManager = playlistManager;
- }
-
- /// <inheritdoc/>
- public void Perform()
- {
- var playlists = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = [BaseItemKind.Playlist]
- })
- .Cast<Playlist>()
- .Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty))
- .ToArray();
-
- if (playlists.Length > 0)
- {
- foreach (var playlist in playlists)
- {
- var linkedChildren = playlist.LinkedChildren;
- if (linkedChildren.Length > 0)
- {
- var newLinkedChildren = linkedChildren
- .Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty))
- .Concat(linkedChildren
- .Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty))
- .DistinctBy(c => c.ItemId))
- .ToArray();
- playlist.LinkedChildren = newLinkedChildren;
- playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
- _playlistManager.SavePlaylistFile(playlist);
- }
- }
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
index c433c1d043..4a6e74c229 100644
--- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
@@ -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,46 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
continue;
}
- var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
+ // 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;
}
@@ -175,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
ParentId = item.Id,
ChildId = childId.Value,
ChildType = childType,
- SortOrder = isPlaylist ? sortOrder : null
+ SortOrder = sortOrder
});
sortOrder++;
@@ -197,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
@@ -267,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);
@@ -418,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)
{
@@ -436,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);
}
}
@@ -443,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.");
@@ -518,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;
@@ -582,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;
diff --git a/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs
new file mode 100644
index 0000000000..16ac6cb5e5
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs
@@ -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;
+ }
+}