Merge remote-tracking branch 'upstream/master' into fix-byname-queries

# Conflicts:
#	src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
This commit is contained in:
Shadowghost
2026-08-01 21:41:16 +02:00
33 changed files with 3014 additions and 617 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
@@ -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
@@ -51,5 +51,37 @@
"Music": "Tónleikur",
"UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}",
"HeaderContinueWatching": "Hald áfram at hyggja",
"MusicVideos": "Sjónbandaløg"
"MusicVideos": "Sjónbandaløg",
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
"TaskOptimizeDatabase": "Albøt dátugrunn",
"NameSeasonNumber": "Sesong {0}",
"NameSeasonUnknown": "Ókend sesong",
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
"Undefined": "Óskilmarkað",
"TasksMaintenanceCategory": "Viðlíkahald",
"TaskCleanLogs": "Reinsa gerðalistaskjáttu",
"UserOnlineFromDevice": "{0} er íbundin frá {1}",
"HeaderNextUp": "Næst á skránni",
"NotificationOptionPluginError": "Brek í ískoytisforriti",
"NotificationOptionInstallationFailed": "Innleggingarbrek",
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
"TasksApplicationCategory": "Nýtsluskipan",
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring varð innløgd",
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
"HomeVideos": "Heimaupptøkur",
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
"UserOfflineFromDevice": "{0} breyt av á {1}",
"UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt",
"TasksChannelsCategory": "Alnetsrásir",
"TaskCleanActivityLog": "Reinsa virksemisskrá",
"TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.",
"TaskCleanCache": "Reinsa kovaskjáttu",
"TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.",
"TaskCleanTranscode": "Reinsa umkotuskjáttu",
"TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir",
"TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir",
"CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.",
"CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur"
}
@@ -1,3 +1,27 @@
{
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}",
"Books": "Libres",
"Artists": "Artistas",
"Collections": "Collecciones",
"ChapterNameValue": "Capitol {0}",
"External": "Extèrn",
"Folders": "Dorsièrs",
"Favorites": "Favorits",
"HeaderContinueWatching": "Contunhar de regardar",
"HeaderFavoriteEpisodes": "Episòdis Favorits",
"AuthenticationSucceededWithUserName": "{0} autentificat amb succès",
"HeaderFavoriteShows": "Serias Favoritas",
"HeaderLiveTV": "TV en dirècte",
"HeaderNextUp": "Seguent",
"HearingImpaired": "Amb de deficiéncias auditivas",
"Movies": "Filmes",
"Music": "Musica",
"Latest": "Darrièr",
"Forced": "Forçat",
"Default": "Defaut",
"Genres": "Genres",
"HomeVideos": "Vidèos d'Acuèlh",
"Inherit": "Eiretar",
"LabelIpAddressValue": "Adreça IP: {0}",
"LabelRunningTimeValue": "Temps d'execucion : {0}"
}
@@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists
var playlist = _libraryManager.GetItemById(playlistId) as Playlist
?? throw new ArgumentException("No Playlist exists with Id " + playlistId);
// Retrieve all the items to be added to the playlist
// Retrieve all the items to be added to the playlist.
var newItems = GetPlaylistItems(newItemIds, user, options)
.Where(i => i.SupportsAddingToPlaylist);
// Filter out duplicate items
var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet();
newItems = newItems
.Where(i => !existingIds.Contains(i.Id))
.Distinct();
// Create a list of the new linked children to add to the playlist
var childrenToAdd = newItems
.Select(LinkedChild.Create)
.ToList();
// Log duplicates that have been ignored, if any
int numDuplicates = newItemIds.Count - childrenToAdd.Count;
if (numDuplicates > 0)
{
_logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name);
}
// Do nothing else if there are no items to add to the playlist
if (childrenToAdd.Count == 0)
{
@@ -1607,8 +1607,9 @@ public class DynamicHlsController : BaseJellyfinApiController
if (state.VideoStream is not null && state.IsOutputVideo)
{
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
// fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
// HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx";
}
segmentFormat = "fmp4" + outputFmp4HeaderArg;
@@ -40,6 +40,7 @@ public class UserLibraryController : BaseJellyfinApiController
private readonly IDtoService _dtoService;
private readonly IUserViewManager _userViewManager;
private readonly IFileSystem _fileSystem;
private readonly IProviderManager _providerManager;
/// <summary>
/// Initializes a new instance of the <see cref="UserLibraryController"/> class.
@@ -50,13 +51,15 @@ public class UserLibraryController : BaseJellyfinApiController
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
public UserLibraryController(
IUserManager userManager,
IUserDataManager userDataRepository,
ILibraryManager libraryManager,
IDtoService dtoService,
IUserViewManager userViewManager,
IFileSystem fileSystem)
IFileSystem fileSystem,
IProviderManager providerManager)
{
_userManager = userManager;
_userDataRepository = userDataRepository;
@@ -64,6 +67,7 @@ public class UserLibraryController : BaseJellyfinApiController
_dtoService = dtoService;
_userViewManager = userViewManager;
_fileSystem = fileSystem;
_providerManager = providerManager;
}
/// <summary>
@@ -75,7 +79,7 @@ public class UserLibraryController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
[HttpGet("Items/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<BaseItemDto>> GetItem(
public ActionResult<BaseItemDto> GetItem(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
@@ -94,7 +98,7 @@ public class UserLibraryController : BaseJellyfinApiController
return NotFound();
}
await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
QueueRefreshOnDemandIfNeeded(item);
var dtoOptions = new DtoOptions();
@@ -112,7 +116,7 @@ public class UserLibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
[Obsolete("Kept for backwards compatibility")]
[ApiExplorerSettings(IgnoreApi = true)]
public Task<ActionResult<BaseItemDto>> GetItemLegacy(
public ActionResult<BaseItemDto> GetItemLegacy(
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId)
=> GetItem(userId, itemId);
@@ -639,25 +643,28 @@ public class UserLibraryController : BaseJellyfinApiController
limit,
groupItems);
private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
private void QueueRefreshOnDemandIfNeeded(BaseItem item)
{
if (item is Person)
if (item is not Person)
{
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
if (performFullRefresh)
{
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
ForceSave = true
};
await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
}
return;
}
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
if (hasMetadata || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 3)
{
return;
}
_providerManager.QueueRefresh(
item.Id,
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
ForceSave = true
},
RefreshPriority.High);
}
/// <summary>
@@ -39,6 +39,19 @@ public static class ExpressionExtensions
return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate));
}
/// <summary>
/// Negates a predicate.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
/// <param name="predicate">The predicate expression to negate.</param>
/// <returns>A new expression representing the negation of the input predicate.</returns>
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters);
}
/// <summary>
/// Combines two predicates into a single predicate using a logical AND operation.
/// </summary>
@@ -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)
@@ -621,62 +621,31 @@ public sealed partial class BaseItemRepository
}
/// <inheritdoc />
public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user)
public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(folderIds);
ArgumentNullException.ThrowIfNull(user);
var filter = new InternalItemsQuery(user);
var userId = user.Id;
var leafItems = context.BaseItems
.AsNoTracking()
.Where(b => !b.IsFolder && !b.IsVirtualItem);
leafItems = ApplyAccessFiltering(context, leafItems, filter);
.Where(e => !e.IsFolder && !e.IsVirtualItem);
var playedLeafItems = leafItems
.Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
}
var ancestorLeaves = context.AncestorIds
.Where(a => folderIds.Contains(a.ParentItemId))
.Join(
playedLeafItems,
a => a.ItemId,
b => b.Id,
(a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
/// <inheritdoc />
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(descendants);
var linkedLeaves = context.LinkedChildren
.Where(lc => folderIds.Contains(lc.ParentId))
.Join(
playedLeafItems,
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
var linkedFolderLeaves = context.LinkedChildren
.Where(lc => folderIds.Contains(lc.ParentId))
.Join(
context.BaseItems.Where(b => b.IsFolder),
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { lc.ParentId, FolderChildId = b.Id })
.Join(
context.AncestorIds,
x => x.FolderChildId,
a => a.ParentItemId,
(x, a) => new { x.ParentId, DescendantId = a.ItemId })
.Join(
playedLeafItems,
x => x.DescendantId,
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
return ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
.Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count())
.Select(g => g.Key);
// Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as
// linked children, which can themselves be folders contributing their own descendants.
// Every step is a correlated index seek, so only the rows the outer query keeps are visited
// and a folder is left as soon as its first matching descendant is found.
return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId))
|| context.LinkedChildren.Any(lc => lc.ParentId == e.Id
&& (descendants.Any(d => d.Id == lc.ChildId)
|| context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId))));
}
}
@@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository
private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant();
private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant();
// A fresh expression per access: EF rejects a query tree that reuses one lambda parameter
// instance across several lambdas, and this filter is combined into a tree more than once.
private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
/// <inheritdoc />
public IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
@@ -466,97 +470,45 @@ public sealed partial class BaseItemRepository
if (filter.IsPlayed.HasValue)
{
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet);
var userId = filter.User!.Id;
if (hasSeries || hasBoxSet)
{
var userId = filter.User!.Id;
var isPlayed = filter.IsPlayed.Value;
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
// Leaf items carry their own played state.
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
// Series: played = at least one episode AND all episodes played; unplayed = otherwise.
IQueryable<Guid> playedSeriesIds = hasSeries
? context.BaseItems
.AsNoTracking()
.Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
.GroupBy(e => e.SeriesId!.Value)
.Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
.Select(g => g.Key)
: Enumerable.Empty<Guid>().AsQueryable();
// Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
// descendant is left unplayed, matching what the DTO reports for them. This has to key off
// the item itself rather than off the requested item types: tag and collection listings mix
// folders and leaf items in a single query.
var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!)
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
// BoxSet: played = all children played.
IQueryable<Guid> playedBoxSetIds = hasBoxSet
? GetFullyPlayedFolderIdsQuery(
context,
baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id),
filter.User!)
: Enumerable.Empty<Guid>().AsQueryable();
var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
// Non-folder items: check UserData directly
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
if (isPlayed)
{
baseQuery = baseQuery.Where(e =>
(e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id))
|| (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id))
|| (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id)));
}
else
{
baseQuery = baseQuery.Where(e =>
(e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id))
|| (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id))
|| (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id)));
}
}
else
{
var playedItemIds = context.UserData
.Where(ud => ud.UserId == filter.User!.Id && ud.Played)
.Select(ud => ud.ItemId);
var isPlayedItem = filter.IsPlayed.Value;
baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem);
}
baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not());
}
if (filter.IsResumable.HasValue)
{
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
var userId = filter.User!.Id;
var isResumable = filter.IsResumable.Value;
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
// In-progress user data rows; alternate versions track their own progress.
var inProgress = context.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
IQueryable<Guid>? resumableSeriesIds = null;
if (hasSeries)
{
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
var seriesEpisodeStats = context.BaseItems
.AsNoTracking()
.Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
.GroupBy(e => e.SeriesId!.Value)
.Select(g => new
{
SeriesId = g.Key,
HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)),
HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)),
HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
});
// Folders are resumable when a descendant is in progress, or when they hold both played and
// unplayed descendants (partially watched). Alternate versions keep their own progress, so
// they count towards the in-progress check but not towards the played/unplayed one.
var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!);
var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true)
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0));
// A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched).
resumableSeriesIds = seriesEpisodeStats
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId);
}
var folderResumableFilter = BuildHasDescendantFilter(context, inProgressLeafItems)
.Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
.And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))));
if (isResumable)
{
@@ -564,18 +516,15 @@ public sealed partial class BaseItemRepository
// Match each version on its own progress rather than coalescing onto the primary.
var inProgressIds = inProgress.Select(ud => ud.ItemId);
baseQuery = hasSeries
? baseQuery.Where(e =>
(e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
|| (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
: baseQuery.Where(e => inProgressIds.Contains(e.Id));
baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter)
.Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id))));
// When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
// Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
// which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
// the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
// Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those.
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName
baseQuery = baseQuery.Where(e => e.IsFolder
|| (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id))
|| !context.BaseItems
.Where(s => s.Id != e.Id
@@ -594,11 +543,8 @@ public sealed partial class BaseItemRepository
var resumableMovieIds = inProgress
.Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
baseQuery = hasSeries
? baseQuery.Where(e =>
(e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
|| (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
: baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter.Not())
.Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
}
}
@@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService
switch (kind)
{
case BaseItemKind.Person:
baseQuery = context.PeopleBaseItemMap
baseQuery = ItemsById(context, context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => m.People.Name == item.Name)
.Select(m => m.Item);
.Select(m => m.ItemId));
break;
case BaseItemKind.MusicArtist:
baseQuery = context.ItemValuesMap
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
.Select(ivm => ivm.Item);
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Genre:
case BaseItemKind.MusicGenre:
baseQuery = context.ItemValuesMap
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Genre)
.Select(ivm => ivm.Item);
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Studio:
baseQuery = context.ItemValuesMap
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Studios)
.Select(ivm => ivm.Item);
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Year:
if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
@@ -254,6 +254,9 @@ public class ItemCountService : IItemCountService
return result;
}
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
=> context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
@@ -428,106 +428,144 @@ public class ItemPersistenceService : IItemPersistenceService
foreach (var item in tuples)
{
if (item.Item is Folder folder)
// 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 })
{
var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
if (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))
.Select(lc => lc.Path)
.Distinct()
continue;
}
if (item.Item is Folder or Video
&& allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
&& existingLinks.Count > 0)
{
// 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();
var pathToIdMap = pathsToResolve.Count > 0
? context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id)
: [];
if (staleLinks.Count > 0)
{
context.LinkedChildren.RemoveRange(staleLinks);
}
}
}
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
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 { 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 => !string.IsNullOrEmpty(lc.Path)
&& (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)))
.Select(lc => lc.Path)
.Distinct()
.ToList();
var pathToIdMap = pathsToResolve.Count > 0
? context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id)
: [];
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value))
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty())
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
childItemId = resolvedId;
}
childItemId = resolvedId;
}
#pragma warning restore CS0618
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty())
{
resolvedChildren.Add((linkedChild, childItemId.Value));
childItemId = libraryItemId;
}
}
#pragma warning restore CS0618
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
{
resolvedChildren.Add((linkedChild, childItemId.Value));
}
}
// Playlists may legitimately contain the same item multiple times (e.g. a song repeated
// in an .m3u file). Every other container type keeps a single entry per child.
var isPlaylist = folder is Playlist;
if (!isPlaylist)
{
resolvedChildren = resolvedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.ToList();
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
var isPlaylist = folder is Playlist;
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
if (!existingChildIds.Contains(childId))
{
_logger.LogWarning(
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
item.Item.Name,
item.Item.Id,
childId);
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
{
context.LinkedChildren.Add(new LinkedChildEntity()
{
ParentId = item.Item.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)linkedChild.Type,
SortOrder = isPlaylist ? sortOrder : null
});
}
else
{
existingLink.SortOrder = isPlaylist ? sortOrder : null;
existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
existingLinkedChildren.Remove(existingLink);
}
sortOrder++;
}
}
if (existingLinkedChildren.Count > 0)
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
context.LinkedChildren.RemoveRange(existingLinkedChildren);
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} (path {ChildPath}) does not exist in database",
item.Item.Name,
item.Item.Id,
childId,
linkedChild.Path ?? "unknown");
#pragma warning restore CS0618
continue;
}
context.LinkedChildren.Add(new LinkedChildEntity()
{
ParentId = item.Item.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)linkedChild.Type,
SortOrder = sortOrder
});
sortOrder++;
}
}
if (item.Item is Video video)
{
var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>())
.Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
.ToList();
var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
if (video.LocalAlternateVersions.Length > 0)
@@ -577,7 +615,7 @@ public class ItemPersistenceService : IItemPersistenceService
.ToHashSet()
: [];
int sortOrder = 0;
var sortOrder = 0;
foreach (var (childId, childType) in newLinkedChildren)
{
if (!existingChildIds.Contains(childId))
@@ -590,36 +628,27 @@ public class ItemPersistenceService : IItemPersistenceService
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
context.LinkedChildren.Add(new LinkedChildEntity
{
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = video.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)childType,
SortOrder = sortOrder
});
}
else
{
existingLink.ChildType = (DbLinkedChildType)childType;
existingLink.SortOrder = sortOrder;
existingLinkedChildren.Remove(existingLink);
}
ParentId = video.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)childType,
SortOrder = sortOrder
});
sortOrder++;
}
if (existingLinkedChildren.Count > 0)
// A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
if (previousLinkedChildren is { Count: > 0 })
{
var orphanedLocalVersionIds = existingLinkedChildren
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
var orphanedLocalVersionIds = previousLinkedChildren
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId))
.Select(e => e.ChildId)
.ToList();
context.LinkedChildren.RemoveRange(existingLinkedChildren);
if (orphanedLocalVersionIds.Count > 0)
{
var orphanedItems = context.BaseItems
@@ -159,12 +159,16 @@ public class LinkedChildrenService : ILinkedChildrenService
if (existingLink is null)
{
var nextSortOrder = (context.LinkedChildren
.Where(lc => lc.ParentId == parentId)
.Max(lc => (int?)lc.SortOrder) ?? -1) + 1;
context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = dbChildType,
SortOrder = null
SortOrder = nextSortOrder
});
}
else
@@ -87,8 +87,9 @@ public static class StorageHelper
/// </summary>
private static string ResolvePath(string path)
{
var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
var current = Path.DirectorySeparatorChar.ToString();
var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString();
var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
var current = root;
foreach (var part in parts)
{
current = Path.Combine(current, part);
@@ -193,10 +193,15 @@ internal class JellyfinMigrationService
{
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
(string Key, IInternalMigration Migration)[] migrations = [];
var completedMigrations = 0;
string? lastMigrationKey = null;
do
{ // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply.
while (true)
{
// A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the
// state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations
// with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before
// that point is stale, so only ever run the next migration and then work out the pending set again.
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
var pendingCodeMigrations = migrationStage
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
@@ -212,73 +217,86 @@ internal class JellyfinMigrationService
}
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
var migrationIndex = 0;
foreach (var item in migrations)
if (pendingMigrations.Length == 0)
{
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
{
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
break;
}
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
if (completedMigrations == 0)
{
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage);
}
var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First();
if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely.");
}
lastMigrationKey = item.Key;
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length));
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
{
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
{
if (_backupKey.LibraryDb is not null)
{
if (_backupKey.LibraryDb is not null)
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
if (_backupKey.JellyfinDb is not null)
catch (Exception inner)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
}
throw;
if (_backupKey.JellyfinDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
}
}
throw;
}
} while (migrations.Length != 0);
completedMigrations++;
}
}
}
@@ -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);
}
}
}
}
}
@@ -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;
@@ -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; }
@@ -461,11 +461,12 @@ namespace MediaBrowser.Controller.Entities
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
var isPlayedValue = query.IsPlayed.Value;
return itemList.Where(i =>
return itemList.Where(item =>
{
if (i.IsFolder && counts.TryGetValue(i.Id, out var c))
if (item is Folder)
{
return (c.Total > 0 && c.Played == c.Total) == isPlayedValue;
var itemCount = counts.GetValueOrDefault(item.Id);
return (itemCount.Played >= itemCount.Total) == isPlayedValue;
}
return true;
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
@@ -79,17 +80,26 @@ public interface IItemQueryHelpers
Guid ancestorId);
/// <summary>
/// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played
/// for the given user. Composable into outer queries to avoid an extra DB roundtrip.
/// Builds a query for the playable leaf items a user can access.
/// </summary>
/// <param name="context">The database context the resulting query is bound to.</param>
/// <param name="folderIds">A query yielding candidate folder IDs.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns>
IQueryable<Guid> GetFullyPlayedFolderIdsQuery(
/// <param name="user">The user to filter accessible items for.</param>
/// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param>
/// <returns>The access-filtered leaf item queryable.</returns>
IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(
JellyfinDbContext context,
IQueryable<Guid> folderIds,
User user);
User user,
bool includeOwnedItems = false);
/// <summary>
/// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them.
/// </summary>
/// <param name="context">The database context the resulting filter is bound to.</param>
/// <param name="descendants">A query yielding the descendants to look for.</param>
/// <returns>A filter expression matching items with a matching descendant.</returns>
Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(
JellyfinDbContext context,
IQueryable<BaseItemEntity> descendants);
/// <summary>
/// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>.
@@ -1038,6 +1038,11 @@ namespace MediaBrowser.Providers.Manager
target.OriginalTitle = source.OriginalTitle;
}
if (replaceData || string.IsNullOrEmpty(target.HomePageUrl))
{
target.HomePageUrl = source.HomePageUrl;
}
if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage))
{
target.OriginalLanguage = source.OriginalLanguage;
@@ -3,32 +3,24 @@
#pragma warning disable CS1591
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbArtistImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory)
public AudioDbArtistImageProvider(IHttpClientFactory httpClientFactory)
{
_config = config;
_httpClientFactory = httpClientFactory;
}
@@ -54,22 +46,14 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id))
item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var musicBrainzId);
item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var audioDbId);
var artist = await AudioDbArtistProvider.Current.GetArtist(musicBrainzId, audioDbId, cancellationToken).ConfigureAwait(false);
if (artist is not null)
{
await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false);
var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
{
return GetImages(obj.artists[0]);
}
}
return GetImages(artist);
}
return [];
@@ -4,9 +4,11 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -52,45 +54,176 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
public int Order => 1;
/// <inheritdoc />
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
=> Task.FromResult(Enumerable.Empty<RemoteSearchResult>());
/// <inheritdoc />
public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken)
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicArtist>();
var id = info.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(id))
// Prefer a known TheAudioDB artist id.
var audioDbId = searchInfo.GetProviderId(MetadataProvider.AudioDbArtist);
if (!string.IsNullOrWhiteSpace(audioDbId))
{
await EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false);
var artists = await FetchArtists(BaseUrl + "/artist.php?i=" + audioDbId, cancellationToken).ConfigureAwait(false);
return artists.Select(ToRemoteSearchResult);
}
var path = GetArtistInfoPath(_config.ApplicationPaths, id);
// Fall back to the MusicBrainz artist id, reusing the on-disk cache also used by GetMetadata.
var musicBrainzId = searchInfo.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(musicBrainzId))
{
await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false);
var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
if (obj is not null && obj.artists is not null)
{
result.Item = new MusicArtist();
result.HasMetadata = true;
ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage);
return obj.artists.Select(ToRemoteSearchResult);
}
}
return [];
}
// Finally, search by name.
if (!string.IsNullOrWhiteSpace(searchInfo.Name))
{
var artists = await FetchArtists(BaseUrl + "/search.php?s=" + Uri.EscapeDataString(searchInfo.Name), cancellationToken).ConfigureAwait(false);
return artists.Select(ToRemoteSearchResult);
}
return [];
}
private async Task<List<Artist>> FetchArtists(string url, CancellationToken cancellationToken)
{
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var obj = await response.Content.ReadFromJsonAsync<RootObject>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return obj?.artists ?? [];
}
private RemoteSearchResult ToRemoteSearchResult(Artist artist)
{
var result = new RemoteSearchResult
{
Name = artist.strArtist,
ImageUrl = artist.strArtistThumb,
SearchProviderName = Name,
Overview = (artist.strBiographyEN ?? string.Empty).StripHtml()
};
if (!string.IsNullOrEmpty(artist.idArtist))
{
result.SetProviderId(MetadataProvider.AudioDbArtist, artist.idArtist);
}
if (!string.IsNullOrEmpty(artist.strMusicBrainzID))
{
result.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.strMusicBrainzID);
}
if (int.TryParse(artist.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear))
{
result.ProductionYear = formedYear;
}
return result;
}
/// <inheritdoc />
public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicArtist>();
var artist = await GetArtist(
info.GetMusicBrainzArtistId(),
info.GetProviderId(MetadataProvider.AudioDbArtist),
cancellationToken).ConfigureAwait(false);
if (artist is not null)
{
result.Item = new MusicArtist();
result.HasMetadata = true;
ProcessResult(result.Item, artist, info.MetadataLanguage);
}
return result;
}
/// <summary>
/// Resolves the cached AudioDB artist, preferring the MusicBrainz id and falling back to the AudioDB id.
/// </summary>
/// <param name="musicBrainzId">The MusicBrainz artist id, if known.</param>
/// <param name="audioDbId">The TheAudioDB artist id, if known.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The matching artist, or <c>null</c> if none could be resolved.</returns>
internal async Task<Artist> GetArtist(string musicBrainzId, string audioDbId, CancellationToken cancellationToken)
{
string path;
if (!string.IsNullOrWhiteSpace(musicBrainzId))
{
await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false);
path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
}
else if (!string.IsNullOrWhiteSpace(audioDbId))
{
await EnsureArtistInfoByAudioDbId(audioDbId, cancellationToken).ConfigureAwait(false);
path = GetArtistInfoPath(_config.ApplicationPaths, audioDbId);
}
else
{
return null;
}
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
{
return obj.artists[0];
}
}
return null;
}
private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage)
{
// item.HomePageUrl = result.strWebsite;
if (!string.IsNullOrEmpty(result.strGenre))
if (!string.IsNullOrWhiteSpace(result.strWebsite))
{
item.Genres = new[] { result.strGenre };
item.HomePageUrl = result.strWebsite;
}
var genres = new List<string>();
if (!string.IsNullOrWhiteSpace(result.strGenre))
{
genres.Add(result.strGenre);
}
if (!string.IsNullOrWhiteSpace(result.strSubGenre))
{
genres.Add(result.strSubGenre);
}
if (genres.Count > 0)
{
item.Genres = genres.ToArray();
}
if (int.TryParse(result.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear))
{
item.ProductionYear = formedYear;
}
if (!string.IsNullOrWhiteSpace(result.strCountry))
{
item.ProductionLocations = new[] { result.strCountry };
}
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
@@ -150,13 +283,32 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var url = BaseUrl + "/artist-mb.php?i=" + musicBrainzId;
await DownloadArtistInfo(url, GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId), cancellationToken).ConfigureAwait(false);
}
internal async Task EnsureArtistInfoByAudioDbId(string audioDbId, CancellationToken cancellationToken)
{
var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, audioDbId);
var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath);
if (fileInfo.Exists
&& (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2)
{
return;
}
var url = BaseUrl + "/artist.php?i=" + audioDbId;
await DownloadArtistInfo(url, xmlPath, cancellationToken).ConfigureAwait(false);
}
private async Task DownloadArtistInfo(string url, string path, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
Directory.CreateDirectory(Path.GetDirectoryName(path));
var fileStreamOptions = AsyncFile.WriteOptions;
@@ -155,7 +155,6 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
/// <inheritdoc />
public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
{
// TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it.
var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
var releaseId = info.GetReleaseId();
var releaseGroupId = info.GetReleaseGroupId();
@@ -169,13 +168,8 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId))
{
// TODO: Actually try to match the release. Simply taking the first result is stupid.
var releaseGroup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null;
if (release is not null)
{
releaseId = release.Id.ToString();
result.HasMetadata = true;
}
var releaseGroupLookup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
releaseId = releaseGroupLookup.Releases?.Count > 0 ? releaseGroupLookup.Releases[0].Id.ToString() : null;
}
// If there is no release ID, lookup a release with the info we have
@@ -205,43 +199,106 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
{
releaseGroupId = releaseResult.ReleaseGroup.Id.ToString();
}
result.HasMetadata = true;
result.Item.ProductionYear = releaseResult.Date?.Year;
result.Item.Overview = releaseResult.Annotation;
}
}
// If we have a release ID but not a release group ID, lookup the release group
if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
if (string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
{
var release = await query.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
releaseGroupId = release.ReleaseGroup?.Id.ToString();
result.HasMetadata = true;
return result;
}
// If we have a release ID and a release group ID
if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId))
// Fetch the full release (and its release group) so we can populate everything MusicBrainz returns.
IRelease? release = null;
if (!string.IsNullOrWhiteSpace(releaseId))
{
result.HasMetadata = true;
}
release = await query.LookupReleaseAsync(
new Guid(releaseId),
Include.Artists | Include.ReleaseGroups | Include.Labels | Include.Genres | Include.Tags,
cancellationToken).ConfigureAwait(false);
if (result.HasMetadata)
{
if (!string.IsNullOrEmpty(releaseId))
if (string.IsNullOrWhiteSpace(releaseGroupId) && release?.ReleaseGroup?.Id is not null)
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId);
}
if (!string.IsNullOrEmpty(releaseGroupId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId);
releaseGroupId = release.ReleaseGroup.Id.ToString();
}
}
IReleaseGroup? releaseGroup = null;
if (!string.IsNullOrWhiteSpace(releaseGroupId))
{
releaseGroup = await query.LookupReleaseGroupAsync(
new Guid(releaseGroupId),
Include.Artists | Include.Genres | Include.Tags,
null,
cancellationToken).ConfigureAwait(false);
}
result.HasMetadata = true;
if (!string.IsNullOrEmpty(releaseId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId);
}
if (!string.IsNullOrEmpty(releaseGroupId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId);
}
Populate(result.Item, release, releaseGroup);
return result;
}
private static void Populate(MusicAlbum item, IRelease? release, IReleaseGroup? releaseGroup)
{
// Prefer the release group (album-level) data, falling back to the specific release.
// The release group's first release date is the original album date.
var date = releaseGroup?.FirstReleaseDate ?? release?.Date;
if (date is not null)
{
item.PremiereDate = date.NearestDate;
item.ProductionYear = date.Year;
}
var artistCredit = release?.ArtistCredit ?? releaseGroup?.ArtistCredit;
if (artistCredit is not null && artistCredit.Count > 0)
{
item.AlbumArtists = artistCredit
.Select(credit => credit.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
var genres = releaseGroup?.Genres ?? release?.Genres;
if (genres is not null && genres.Count > 0)
{
item.Genres = genres
.OrderByDescending(genre => genre.VoteCount)
.Select(genre => genre.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
var tags = releaseGroup?.Tags ?? release?.Tags;
if (tags is not null && tags.Count > 0)
{
item.Tags = tags
.OrderByDescending(tag => tag.VoteCount)
.Select(tag => tag.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
if (release?.LabelInfo is not null && release.LabelInfo.Count > 0)
{
item.Studios = release.LabelInfo
.Where(labelInfo => !string.IsNullOrWhiteSpace(labelInfo.Label?.Name))
.Select(labelInfo => labelInfo.Label!.Name!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
@@ -40,6 +40,11 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
return GetResultFromResponse(artistResult).SingleItemAsEnumerable();
}
if (string.IsNullOrWhiteSpace(searchInfo.Name))
{
return [];
}
var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (artistSearchResults.Results.Count > 0)
@@ -58,7 +63,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
}
}
return Enumerable.Empty<RemoteSearchResult>();
return [];
}
private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults)
@@ -96,28 +101,67 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
var musicBrainzId = info.GetMusicBrainzArtistId();
// If we don't have an id yet, resolve one by name so we can look the artist up.
if (string.IsNullOrWhiteSpace(musicBrainzId))
{
var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false);
var singleResult = searchResults.FirstOrDefault();
if (singleResult is not null)
{
musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist);
result.Item.Overview = singleResult.Overview;
if (Plugin.Instance!.Configuration.ReplaceArtistName)
{
result.Item.Name = singleResult.Name;
}
}
musicBrainzId = searchResults.FirstOrDefault()?.GetProviderId(MetadataProvider.MusicBrainzArtist);
}
if (!string.IsNullOrWhiteSpace(musicBrainzId))
if (string.IsNullOrWhiteSpace(musicBrainzId))
{
result.HasMetadata = true;
result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId);
return result;
}
var query = Plugin.Instance!.MusicBrainzQuery;
var artist = await query.LookupArtistAsync(new Guid(musicBrainzId), Include.Genres | Include.Tags, null, null, cancellationToken).ConfigureAwait(false);
if (artist is null)
{
return result;
}
result.HasMetadata = true;
result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString());
if (Plugin.Instance!.Configuration.ReplaceArtistName && !string.IsNullOrWhiteSpace(artist.Name))
{
result.Item.Name = artist.Name;
}
if (artist.LifeSpan?.Begin is not null)
{
result.Item.PremiereDate = artist.LifeSpan.Begin.NearestDate;
result.Item.ProductionYear = artist.LifeSpan.Begin.Year;
}
if (artist.LifeSpan?.End is not null)
{
result.Item.EndDate = artist.LifeSpan.End.NearestDate;
}
var location = string.IsNullOrWhiteSpace(artist.Area?.Name) ? artist.Country : artist.Area!.Name;
if (!string.IsNullOrWhiteSpace(location))
{
result.Item.ProductionLocations = [location];
}
if (artist.Genres is not null && artist.Genres.Count > 0)
{
result.Item.Genres = artist.Genres
.OrderByDescending(genre => genre.VoteCount)
.Select(genre => genre.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
if (artist.Tags is not null && artist.Tags.Count > 0)
{
result.Item.Tags = artist.Tags
.OrderByDescending(tag => tag.VoteCount)
.Select(tag => tag.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
return result;
@@ -25,7 +25,7 @@ public class LinkedChildEntity
/// <summary>
/// Gets or sets the sort order.
/// </summary>
public int? SortOrder { get; set; }
public int SortOrder { get; set; }
/// <summary>
/// Gets or sets the parent item navigation property.
@@ -13,8 +13,7 @@ public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEnti
public void Configure(EntityTypeBuilder<LinkedChildEntity> builder)
{
builder.ToTable("LinkedChildren");
builder.HasKey(e => new { e.ParentId, e.ChildId });
builder.HasIndex(e => new { e.ParentId, e.SortOrder });
builder.HasKey(e => new { e.ParentId, e.SortOrder });
builder.HasIndex(e => new { e.ParentId, e.ChildType });
builder.HasIndex(e => new { e.ChildId, e.ChildType });
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AllowDuplicatePlaylistChildren : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Rows that predate the composite (ParentId, SortOrder) primary key stored a null SortOrder
// (e.g. BoxSet and Collection children). Assign each such row a stable 0-based position within
// its parent so the rows stay unique once SortOrder becomes part of the primary key; otherwise
// they would all collapse to the column default (0) and collide during the table rebuild.
migrationBuilder.Sql(
@"UPDATE ""LinkedChildren""
SET ""SortOrder"" = (
SELECT COUNT(*)
FROM ""LinkedChildren"" AS lc2
WHERE lc2.""ParentId"" = ""LinkedChildren"".""ParentId""
AND lc2.""rowid"" < ""LinkedChildren"".""rowid""
)
WHERE ""SortOrder"" IS NULL;");
migrationBuilder.DropPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren");
migrationBuilder.DropIndex(
name: "IX_LinkedChildren_ParentId_SortOrder",
table: "LinkedChildren");
migrationBuilder.AlterColumn<int>(
name: "SortOrder",
table: "LinkedChildren",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AddPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren",
columns: new[] { "ParentId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// The (ParentId, ChildId) primary key cannot represent the same child more than once per
// parent. Drop any duplicate entries (keeping the first by SortOrder) that may have been
// created while duplicates were allowed, so the old key can be restored. This is lossy by
// nature — duplicate playlist entries cannot survive a downgrade.
migrationBuilder.Sql(
@"DELETE FROM ""LinkedChildren""
WHERE ""rowid"" NOT IN (
SELECT MIN(""rowid"")
FROM ""LinkedChildren""
GROUP BY ""ParentId"", ""ChildId""
);");
migrationBuilder.DropPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren");
migrationBuilder.AlterColumn<int>(
name: "SortOrder",
table: "LinkedChildren",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AddPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren",
columns: new[] { "ParentId", "ChildId" });
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ParentId_SortOrder",
table: "LinkedChildren",
columns: new[] { "ParentId", "SortOrder" });
}
}
}
@@ -815,23 +815,21 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<Guid>("ParentId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<Guid>("ChildId")
.HasColumnType("TEXT");
b.Property<int>("ChildType")
.HasColumnType("INTEGER");
b.Property<int?>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("ParentId", "ChildId");
b.HasKey("ParentId", "SortOrder");
b.HasIndex("ChildId", "ChildType");
b.HasIndex("ParentId", "ChildType");
b.HasIndex("ParentId", "SortOrder");
b.ToTable("LinkedChildren", (string)null);
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);