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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-2
@@ -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 });
|
||||
|
||||
|
||||
+1808
File diff suppressed because it is too large
Load Diff
+89
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user