diff options
Diffstat (limited to 'Jellyfin.Server')
13 files changed, 1359 insertions, 43 deletions
diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs index 5518d6ba8..bbf6d31f1 100644 --- a/Jellyfin.Server/Helpers/StartupHelpers.cs +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -83,7 +83,7 @@ public static class StartupHelpers var dataDir = options.DataDir ?? Environment.GetEnvironmentVariable("JELLYFIN_DATA_DIR") ?? Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify), "jellyfin"); var configDir = options.ConfigDir ?? Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR"); @@ -97,7 +97,7 @@ public static class StartupHelpers { // UNIX: $XDG_CONFIG_HOME configDir = Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.DoNotVerify), "jellyfin"); } } @@ -163,7 +163,7 @@ public static class StartupHelpers if (cacheHome is null || !cacheHome.StartsWith('/')) { cacheHome = Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify), ".cache"); } @@ -292,13 +292,5 @@ public static class StartupHelpers // Make sure we have all the code pages we can get // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - - // Increase the max http request limit - // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others. - ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); - - // Disable the "Expect: 100-Continue" header by default - // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c - ServicePointManager.Expect100Continue = false; } } diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs index 801026c54..910b5c467 100644 --- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs +++ b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs @@ -67,41 +67,43 @@ namespace Jellyfin.Server.Infrastructure } /// <inheritdoc /> - protected override Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) + protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(result); if (range is not null && rangeLength == 0) { - return Task.CompletedTask; + return; } // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code if (!IsSymLink(result.FileName)) { - return base.WriteFileAsync(context, result, range, rangeLength); + await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false); + return; } var response = context.HttpContext.Response; if (range is not null) { - return SendFileAsync( + await SendFileAsync( result.FileName, response, offset: range.From ?? 0L, - count: rangeLength); + count: rangeLength).ConfigureAwait(false); + return; } - return SendFileAsync( + await SendFileAsync( result.FileName, response, offset: 0, - count: null); + count: null).ConfigureAwait(false); } - private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count) + private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default) { var fileInfo = GetFileInfo(filePath); if (offset < 0 || offset > fileInfo.Length) @@ -118,6 +120,9 @@ namespace Jellyfin.Server.Infrastructure // Copied from SendFileFallback.SendFileAsync const int BufferSize = 1024 * 16; + var useRequestAborted = !cancellationToken.CanBeCanceled; + var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken; + var fileStream = new FileStream( filePath, FileMode.Open, @@ -127,10 +132,17 @@ namespace Jellyfin.Server.Infrastructure options: FileOptions.Asynchronous | FileOptions.SequentialScan); await using (fileStream.ConfigureAwait(false)) { - fileStream.Seek(offset, SeekOrigin.Begin); - await StreamCopyOperation - .CopyToAsync(fileStream, response.Body, count, BufferSize, CancellationToken.None) - .ConfigureAwait(true); + try + { + localCancel.ThrowIfCancellationRequested(); + fileStream.Seek(offset, SeekOrigin.Begin); + await StreamCopyOperation + .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel) + .ConfigureAwait(true); + } + catch (OperationCanceledException) when (useRequestAborted) + { + } } } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index e18212908..ebb12ba4e 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <AssemblyName>jellyfin</AssemblyName> <OutputType>Exe</OutputType> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <ServerGarbageCollection>false</ServerGarbageCollection> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 9d4441ac3..fa799ae6e 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -47,7 +47,9 @@ namespace Jellyfin.Server.Migrations typeof(Routines.AddDefaultCastReceivers), typeof(Routines.UpdateDefaultPluginRepository), typeof(Routines.FixAudioData), - typeof(Routines.MoveTrickplayFiles) + typeof(Routines.MoveTrickplayFiles), + typeof(Routines.RemoveDuplicatePlaylistChildren), + typeof(Routines.MigrateLibraryDb), }; /// <summary> diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs index 49960f430..09b292171 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS0618 // obsolete + using System; using System.IO; using System.Xml; diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs index ee4f8b0ba..5a8ef2e1c 100644 --- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs +++ b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs @@ -46,7 +46,7 @@ namespace Jellyfin.Server.Migrations.Routines public Guid Id => Guid.Parse("{EF103419-8451-40D8-9F34-D1A8E93A1679}"); /// <inheritdoc/> - public string Name => "CreateLoggingConfigHeirarchy"; + public string Name => "CreateLoggingConfigHierarchy"; /// <inheritdoc/> public bool PerformOnNewInstall => false; diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs index 3655a610d..192c170b2 100644 --- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs +++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs @@ -15,12 +15,12 @@ namespace Jellyfin.Server.Migrations.Routines; /// </summary> internal class FixPlaylistOwner : IMigrationRoutine { - private readonly ILogger<RemoveDuplicateExtras> _logger; + private readonly ILogger<FixPlaylistOwner> _logger; private readonly ILibraryManager _libraryManager; private readonly IPlaylistManager _playlistManager; public FixPlaylistOwner( - ILogger<RemoveDuplicateExtras> logger, + ILogger<FixPlaylistOwner> logger, ILibraryManager libraryManager, IPlaylistManager playlistManager) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs new file mode 100644 index 000000000..632ff9307 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -0,0 +1,1201 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Data; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Emby.Server.Implementations.Data; +using Jellyfin.Data.Entities; +using Jellyfin.Extensions; +using Jellyfin.Server.Implementations; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Chapter = Jellyfin.Data.Entities.Chapter; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// The migration routine for migrating the userdata database to EF Core. +/// </summary> +public class MigrateLibraryDb : IMigrationRoutine +{ + private const string DbFilename = "library.db"; + + private readonly ILogger<MigrateLibraryDb> _logger; + private readonly IServerApplicationPaths _paths; + private readonly IDbContextFactory<JellyfinDbContext> _provider; + + /// <summary> + /// Initializes a new instance of the <see cref="MigrateLibraryDb"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="provider">The database provider.</param> + /// <param name="paths">The server application paths.</param> + public MigrateLibraryDb( + ILogger<MigrateLibraryDb> logger, + IDbContextFactory<JellyfinDbContext> provider, + IServerApplicationPaths paths) + { + _logger = logger; + _provider = provider; + _paths = paths; + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("36445464-849f-429f-9ad0-bb130efa0664"); + + /// <inheritdoc/> + public string Name => "MigrateLibraryDbData"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; // TODO Change back after testing + + /// <inheritdoc/> + public void Perform() + { + _logger.LogInformation("Migrating the userdata from library.db may take a while, do not stop Jellyfin."); + + var dataPath = _paths.DataPath; + var libraryDbPath = Path.Combine(dataPath, DbFilename); + using var connection = new SqliteConnection($"Filename={libraryDbPath}"); + var migrationTotalTime = TimeSpan.Zero; + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + connection.Open(); + using var dbContext = _provider.CreateDbContext(); + + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving UserData entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving TypedBaseItem."); + const string typedBaseItemsQuery = """ + SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie, + IsSeries, EpisodeTitle, IsRepeat, CommunityRating, CustomRating, IndexNumber, IsLocked, PreferredMetadataLanguage, + PreferredMetadataCountryCode, Width, Height, DateLastRefreshed, Name, Path, PremiereDate, Overview, ParentIndexNumber, + ProductionYear, OfficialRating, ForcedSortName, RunTimeTicks, Size, DateCreated, DateModified, Genres, ParentId, TopParentId, + Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId, + DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId, + PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate, + ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType FROM TypedBaseItems + """; + dbContext.BaseItems.ExecuteDelete(); + + var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>(); + foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery)) + { + var baseItem = GetItem(dto); + dbContext.BaseItems.Add(baseItem.BaseItem); + foreach (var dataKey in baseItem.LegacyUserDataKey) + { + legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem; + } + } + + _logger.LogInformation("Try saving {0} BaseItem entries.", dbContext.BaseItems.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving BaseItems entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving ItemValues."); + // do not migrate inherited types as they are now properly mapped in search and lookup. + const string itemValueQuery = + """ + SELECT ItemId, Type, Value, CleanValue FROM ItemValues + WHERE Type <> 6 AND EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = ItemValues.ItemId) + """; + dbContext.ItemValues.ExecuteDelete(); + + // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow. + var localItems = new Dictionary<(int Type, string CleanValue), (ItemValue ItemValue, List<Guid> ItemIds)>(); + + foreach (SqliteDataReader dto in connection.Query(itemValueQuery)) + { + var itemId = dto.GetGuid(0); + var entity = GetItemValue(dto); + var key = ((int)entity.Type, entity.CleanValue); + if (!localItems.TryGetValue(key, out var existing)) + { + localItems[key] = existing = (entity, []); + } + + existing.ItemIds.Add(itemId); + } + + foreach (var item in localItems) + { + dbContext.ItemValues.Add(item.Value.ItemValue); + dbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new ItemValueMap() + { + Item = null!, + ItemValue = null!, + ItemId = f, + ItemValueId = item.Value.ItemValue.ItemValueId + })); + } + + _logger.LogInformation("Try saving {0} ItemValues entries.", dbContext.ItemValues.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving People ItemValues took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving UserData."); + var queryResult = connection.Query(""" + SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas + + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key) + """); + + dbContext.UserData.ExecuteDelete(); + + var users = dbContext.Users.AsNoTracking().ToImmutableArray(); + var oldUserdata = new Dictionary<string, UserData>(); + + foreach (var entity in queryResult) + { + var userData = GetUserData(users, entity); + if (userData is null) + { + _logger.LogError("Was not able to migrate user data with key {0}", entity.GetString(0)); + continue; + } + + if (!legacyBaseItemWithUserKeys.TryGetValue(userData.CustomDataKey!, out var refItem)) + { + _logger.LogError("Was not able to migrate user data with key {0} because it does not reference a valid BaseItem.", entity.GetString(0)); + continue; + } + + userData.ItemId = refItem.Id; + dbContext.UserData.Add(userData); + } + + _logger.LogInformation("Try saving {0} UserData entries.", dbContext.UserData.Local.Count); + dbContext.SaveChanges(); + + _logger.LogInformation("Start moving MediaStreamInfos."); + const string mediaStreamQuery = """ + SELECT ItemId, StreamIndex, StreamType, Codec, Language, ChannelLayout, Profile, AspectRatio, Path, + IsInterlaced, BitRate, Channels, SampleRate, IsDefault, IsForced, IsExternal, Height, Width, + AverageFrameRate, RealFrameRate, Level, PixelFormat, BitDepth, IsAnamorphic, RefFrames, CodecTag, + Comment, NalLengthSize, IsAvc, Title, TimeBase, CodecTimeBase, ColorPrimaries, ColorSpace, ColorTransfer, + DvVersionMajor, DvVersionMinor, DvProfile, DvLevel, RpuPresentFlag, ElPresentFlag, BlPresentFlag, DvBlSignalCompatibilityId, IsHearingImpaired + FROM MediaStreams + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId) + """; + dbContext.MediaStreamInfos.ExecuteDelete(); + + foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery)) + { + dbContext.MediaStreamInfos.Add(GetMediaStream(dto)); + } + + _logger.LogInformation("Try saving {0} MediaStreamInfos entries.", dbContext.MediaStreamInfos.Local.Count); + dbContext.SaveChanges(); + + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving MediaStreamInfos entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving People."); + const string personsQuery = """ + SELECT ItemId, Name, Role, PersonType, SortOrder FROM People + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId) + """; + dbContext.Peoples.ExecuteDelete(); + dbContext.PeopleBaseItemMap.ExecuteDelete(); + + var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>(); + + foreach (SqliteDataReader reader in connection.Query(personsQuery)) + { + var itemId = reader.GetGuid(0); + if (!dbContext.BaseItems.Any(f => f.Id == itemId)) + { + _logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1)); + continue; + } + + var entity = GetPerson(reader); + if (!peopleCache.TryGetValue(entity.Name, out var personCache)) + { + peopleCache[entity.Name] = personCache = (entity, []); + } + + if (reader.TryGetString(2, out var role)) + { + } + + if (reader.TryGetInt32(4, out var sortOrder)) + { + } + + personCache.Items.Add(new PeopleBaseItemMap() + { + Item = null!, + ItemId = itemId, + People = null!, + PeopleId = personCache.Person.Id, + ListOrder = sortOrder, + SortOrder = sortOrder, + Role = role + }); + } + + foreach (var item in peopleCache) + { + dbContext.Peoples.Add(item.Value.Person); + dbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e.PeopleId))); + } + + _logger.LogInformation("Try saving {0} People entries.", dbContext.MediaStreamInfos.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving People entries took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving Chapters."); + const string chapterQuery = """ + SELECT ItemId,StartPositionTicks,Name,ImagePath,ImageDateModified,ChapterIndex from Chapters2 + WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId) + """; + dbContext.Chapters.ExecuteDelete(); + + foreach (SqliteDataReader dto in connection.Query(chapterQuery)) + { + var chapter = GetChapter(dto); + dbContext.Chapters.Add(chapter); + } + + _logger.LogInformation("Try saving {0} Chapters entries.", dbContext.Chapters.Local.Count); + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving Chapters took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + _logger.LogInformation("Start moving AncestorIds."); + const string ancestorIdsQuery = """ + SELECT ItemId, AncestorId, AncestorIdText FROM AncestorIds + WHERE + EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.ItemId) + AND + EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId) + """; + dbContext.Chapters.ExecuteDelete(); + + foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery)) + { + var ancestorId = GetAncestorId(dto); + dbContext.AncestorIds.Add(ancestorId); + } + + _logger.LogInformation("Try saving {0} AncestorIds entries.", dbContext.Chapters.Local.Count); + + dbContext.SaveChanges(); + migrationTotalTime += stopwatch.Elapsed; + _logger.LogInformation("Saving AncestorIds took {0}.", stopwatch.Elapsed); + stopwatch.Restart(); + + connection.Close(); + _logger.LogInformation("Migration of the Library.db done."); + _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old"); + + SqliteConnection.ClearAllPools(); + File.Move(libraryDbPath, libraryDbPath + ".old"); + + _logger.LogInformation("Migrating Library db took {0}.", migrationTotalTime); + + if (dbContext.Database.IsSqlite()) + { + _logger.LogInformation("Vacuum and Optimise jellyfin.db now."); + dbContext.Database.ExecuteSqlRaw("PRAGMA optimize"); + dbContext.Database.ExecuteSqlRaw("VACUUM"); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + else + { + _logger.LogInformation("This database doesn't support optimization"); + } + } + + private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto) + { + var internalUserId = dto.GetInt32(1); + var user = users.FirstOrDefault(e => e.InternalId == internalUserId); + + if (user is null) + { + _logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length); + return null; + } + + var oldKey = dto.GetString(0); + + return new UserData() + { + ItemId = Guid.NewGuid(), + CustomDataKey = oldKey, + UserId = user.Id, + Rating = dto.IsDBNull(2) ? null : dto.GetDouble(2), + Played = dto.GetBoolean(3), + PlayCount = dto.GetInt32(4), + IsFavorite = dto.GetBoolean(5), + PlaybackPositionTicks = dto.GetInt64(6), + LastPlayedDate = dto.IsDBNull(7) ? null : dto.GetDateTime(7), + AudioStreamIndex = dto.IsDBNull(8) ? null : dto.GetInt32(8), + SubtitleStreamIndex = dto.IsDBNull(9) ? null : dto.GetInt32(9), + Likes = null, + User = null!, + Item = null! + }; + } + + private AncestorId GetAncestorId(SqliteDataReader reader) + { + return new AncestorId() + { + ItemId = reader.GetGuid(0), + ParentItemId = reader.GetGuid(1), + Item = null!, + ParentItem = null! + }; + } + + /// <summary> + /// Gets the chapter. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>ChapterInfo.</returns> + private Chapter GetChapter(SqliteDataReader reader) + { + var chapter = new Chapter + { + StartPositionTicks = reader.GetInt64(1), + ChapterIndex = reader.GetInt32(5), + Item = null!, + ItemId = reader.GetGuid(0), + }; + + if (reader.TryGetString(2, out var chapterName)) + { + chapter.Name = chapterName; + } + + if (reader.TryGetString(3, out var imagePath)) + { + chapter.ImagePath = imagePath; + } + + if (reader.TryReadDateTime(4, out var imageDateModified)) + { + chapter.ImageDateModified = imageDateModified; + } + + return chapter; + } + + private ItemValue GetItemValue(SqliteDataReader reader) + { + return new ItemValue + { + ItemValueId = Guid.NewGuid(), + Type = (ItemValueType)reader.GetInt32(1), + Value = reader.GetString(2), + CleanValue = reader.GetString(3), + }; + } + + private People GetPerson(SqliteDataReader reader) + { + var item = new People + { + Id = Guid.NewGuid(), + Name = reader.GetString(1), + }; + + if (reader.TryGetString(3, out var type)) + { + item.PersonType = type; + } + + return item; + } + + /// <summary> + /// Gets the media stream. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>MediaStream.</returns> + private MediaStreamInfo GetMediaStream(SqliteDataReader reader) + { + var item = new MediaStreamInfo + { + StreamIndex = reader.GetInt32(1), + StreamType = Enum.Parse<MediaStreamTypeEntity>(reader.GetString(2)), + Item = null!, + ItemId = reader.GetGuid(0), + AspectRatio = null!, + ChannelLayout = null!, + Codec = null!, + IsInterlaced = false, + Language = null!, + Path = null!, + Profile = null!, + }; + + if (reader.TryGetString(3, out var codec)) + { + item.Codec = codec; + } + + if (reader.TryGetString(4, out var language)) + { + item.Language = language; + } + + if (reader.TryGetString(5, out var channelLayout)) + { + item.ChannelLayout = channelLayout; + } + + if (reader.TryGetString(6, out var profile)) + { + item.Profile = profile; + } + + if (reader.TryGetString(7, out var aspectRatio)) + { + item.AspectRatio = aspectRatio; + } + + if (reader.TryGetString(8, out var path)) + { + item.Path = path; + } + + item.IsInterlaced = reader.GetBoolean(9); + + if (reader.TryGetInt32(10, out var bitrate)) + { + item.BitRate = bitrate; + } + + if (reader.TryGetInt32(11, out var channels)) + { + item.Channels = channels; + } + + if (reader.TryGetInt32(12, out var sampleRate)) + { + item.SampleRate = sampleRate; + } + + item.IsDefault = reader.GetBoolean(13); + item.IsForced = reader.GetBoolean(14); + item.IsExternal = reader.GetBoolean(15); + + if (reader.TryGetInt32(16, out var width)) + { + item.Width = width; + } + + if (reader.TryGetInt32(17, out var height)) + { + item.Height = height; + } + + if (reader.TryGetSingle(18, out var averageFrameRate)) + { + item.AverageFrameRate = averageFrameRate; + } + + if (reader.TryGetSingle(19, out var realFrameRate)) + { + item.RealFrameRate = realFrameRate; + } + + if (reader.TryGetSingle(20, out var level)) + { + item.Level = level; + } + + if (reader.TryGetString(21, out var pixelFormat)) + { + item.PixelFormat = pixelFormat; + } + + if (reader.TryGetInt32(22, out var bitDepth)) + { + item.BitDepth = bitDepth; + } + + if (reader.TryGetBoolean(23, out var isAnamorphic)) + { + item.IsAnamorphic = isAnamorphic; + } + + if (reader.TryGetInt32(24, out var refFrames)) + { + item.RefFrames = refFrames; + } + + if (reader.TryGetString(25, out var codecTag)) + { + item.CodecTag = codecTag; + } + + if (reader.TryGetString(26, out var comment)) + { + item.Comment = comment; + } + + if (reader.TryGetString(27, out var nalLengthSize)) + { + item.NalLengthSize = nalLengthSize; + } + + if (reader.TryGetBoolean(28, out var isAVC)) + { + item.IsAvc = isAVC; + } + + if (reader.TryGetString(29, out var title)) + { + item.Title = title; + } + + if (reader.TryGetString(30, out var timeBase)) + { + item.TimeBase = timeBase; + } + + if (reader.TryGetString(31, out var codecTimeBase)) + { + item.CodecTimeBase = codecTimeBase; + } + + if (reader.TryGetString(32, out var colorPrimaries)) + { + item.ColorPrimaries = colorPrimaries; + } + + if (reader.TryGetString(33, out var colorSpace)) + { + item.ColorSpace = colorSpace; + } + + if (reader.TryGetString(34, out var colorTransfer)) + { + item.ColorTransfer = colorTransfer; + } + + if (reader.TryGetInt32(35, out var dvVersionMajor)) + { + item.DvVersionMajor = dvVersionMajor; + } + + if (reader.TryGetInt32(36, out var dvVersionMinor)) + { + item.DvVersionMinor = dvVersionMinor; + } + + if (reader.TryGetInt32(37, out var dvProfile)) + { + item.DvProfile = dvProfile; + } + + if (reader.TryGetInt32(38, out var dvLevel)) + { + item.DvLevel = dvLevel; + } + + if (reader.TryGetInt32(39, out var rpuPresentFlag)) + { + item.RpuPresentFlag = rpuPresentFlag; + } + + if (reader.TryGetInt32(40, out var elPresentFlag)) + { + item.ElPresentFlag = elPresentFlag; + } + + if (reader.TryGetInt32(41, out var blPresentFlag)) + { + item.BlPresentFlag = blPresentFlag; + } + + if (reader.TryGetInt32(42, out var dvBlSignalCompatibilityId)) + { + item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId; + } + + item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result; + + // if (reader.TryGetInt32(44, out var rotation)) + // { + // item.Rotation = rotation; + // } + + return item; + } + + private (BaseItemEntity BaseItem, string[] LegacyUserDataKey) GetItem(SqliteDataReader reader) + { + var entity = new BaseItemEntity() + { + Id = reader.GetGuid(0), + Type = reader.GetString(1), + }; + + var index = 2; + + if (reader.TryGetString(index++, out var data)) + { + entity.Data = data; + } + + if (reader.TryReadDateTime(index++, out var startDate)) + { + entity.StartDate = startDate; + } + + if (reader.TryReadDateTime(index++, out var endDate)) + { + entity.EndDate = endDate; + } + + if (reader.TryGetString(index++, out var guid)) + { + entity.ChannelId = guid; + } + + if (reader.TryGetBoolean(index++, out var isMovie)) + { + entity.IsMovie = isMovie; + } + + if (reader.TryGetBoolean(index++, out var isSeries)) + { + entity.IsSeries = isSeries; + } + + if (reader.TryGetString(index++, out var episodeTitle)) + { + entity.EpisodeTitle = episodeTitle; + } + + if (reader.TryGetBoolean(index++, out var isRepeat)) + { + entity.IsRepeat = isRepeat; + } + + if (reader.TryGetSingle(index++, out var communityRating)) + { + entity.CommunityRating = communityRating; + } + + if (reader.TryGetString(index++, out var customRating)) + { + entity.CustomRating = customRating; + } + + if (reader.TryGetInt32(index++, out var indexNumber)) + { + entity.IndexNumber = indexNumber; + } + + if (reader.TryGetBoolean(index++, out var isLocked)) + { + entity.IsLocked = isLocked; + } + + if (reader.TryGetString(index++, out var preferredMetadataLanguage)) + { + entity.PreferredMetadataLanguage = preferredMetadataLanguage; + } + + if (reader.TryGetString(index++, out var preferredMetadataCountryCode)) + { + entity.PreferredMetadataCountryCode = preferredMetadataCountryCode; + } + + if (reader.TryGetInt32(index++, out var width)) + { + entity.Width = width; + } + + if (reader.TryGetInt32(index++, out var height)) + { + entity.Height = height; + } + + if (reader.TryReadDateTime(index++, out var dateLastRefreshed)) + { + entity.DateLastRefreshed = dateLastRefreshed; + } + + if (reader.TryGetString(index++, out var name)) + { + entity.Name = name; + } + + if (reader.TryGetString(index++, out var restorePath)) + { + entity.Path = restorePath; + } + + if (reader.TryReadDateTime(index++, out var premiereDate)) + { + entity.PremiereDate = premiereDate; + } + + if (reader.TryGetString(index++, out var overview)) + { + entity.Overview = overview; + } + + if (reader.TryGetInt32(index++, out var parentIndexNumber)) + { + entity.ParentIndexNumber = parentIndexNumber; + } + + if (reader.TryGetInt32(index++, out var productionYear)) + { + entity.ProductionYear = productionYear; + } + + if (reader.TryGetString(index++, out var officialRating)) + { + entity.OfficialRating = officialRating; + } + + if (reader.TryGetString(index++, out var forcedSortName)) + { + entity.ForcedSortName = forcedSortName; + } + + if (reader.TryGetInt64(index++, out var runTimeTicks)) + { + entity.RunTimeTicks = runTimeTicks; + } + + if (reader.TryGetInt64(index++, out var size)) + { + entity.Size = size; + } + + if (reader.TryReadDateTime(index++, out var dateCreated)) + { + entity.DateCreated = dateCreated; + } + + if (reader.TryReadDateTime(index++, out var dateModified)) + { + entity.DateModified = dateModified; + } + + if (reader.TryGetString(index++, out var genres)) + { + entity.Genres = genres; + } + + if (reader.TryGetGuid(index++, out var parentId)) + { + entity.ParentId = parentId; + } + + if (reader.TryGetGuid(index++, out var topParentId)) + { + entity.TopParentId = topParentId; + } + + if (reader.TryGetString(index++, out var audioString) && Enum.TryParse<ProgramAudioEntity>(audioString, out var audioType)) + { + entity.Audio = audioType; + } + + if (reader.TryGetString(index++, out var serviceName)) + { + entity.ExternalServiceId = serviceName; + } + + if (reader.TryGetBoolean(index++, out var isInMixedFolder)) + { + entity.IsInMixedFolder = isInMixedFolder; + } + + if (reader.TryReadDateTime(index++, out var dateLastSaved)) + { + entity.DateLastSaved = dateLastSaved; + } + + if (reader.TryGetString(index++, out var lockedFields)) + { + entity.LockedFields = lockedFields.Split('|').Select(Enum.Parse<MetadataField>) + .Select(e => new BaseItemMetadataField() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }) + .ToArray(); + } + + if (reader.TryGetString(index++, out var studios)) + { + entity.Studios = studios; + } + + if (reader.TryGetString(index++, out var tags)) + { + entity.Tags = tags; + } + + if (reader.TryGetString(index++, out var trailerTypes)) + { + entity.TrailerTypes = trailerTypes.Split('|').Select(Enum.Parse<TrailerType>) + .Select(e => new BaseItemTrailerType() + { + Id = (int)e, + Item = entity, + ItemId = entity.Id + }) + .ToArray(); + } + + if (reader.TryGetString(index++, out var originalTitle)) + { + entity.OriginalTitle = originalTitle; + } + + if (reader.TryGetString(index++, out var primaryVersionId)) + { + entity.PrimaryVersionId = primaryVersionId; + } + + if (reader.TryReadDateTime(index++, out var dateLastMediaAdded)) + { + entity.DateLastMediaAdded = dateLastMediaAdded; + } + + if (reader.TryGetString(index++, out var album)) + { + entity.Album = album; + } + + if (reader.TryGetSingle(index++, out var lUFS)) + { + entity.LUFS = lUFS; + } + + if (reader.TryGetSingle(index++, out var normalizationGain)) + { + entity.NormalizationGain = normalizationGain; + } + + if (reader.TryGetSingle(index++, out var criticRating)) + { + entity.CriticRating = criticRating; + } + + if (reader.TryGetBoolean(index++, out var isVirtualItem)) + { + entity.IsVirtualItem = isVirtualItem; + } + + if (reader.TryGetString(index++, out var seriesName)) + { + entity.SeriesName = seriesName; + } + + var userDataKeys = new List<string>(); + if (reader.TryGetString(index++, out var directUserDataKey)) + { + userDataKeys.Add(directUserDataKey); + } + + if (reader.TryGetString(index++, out var seasonName)) + { + entity.SeasonName = seasonName; + } + + if (reader.TryGetGuid(index++, out var seasonId)) + { + entity.SeasonId = seasonId; + } + + if (reader.TryGetGuid(index++, out var seriesId)) + { + entity.SeriesId = seriesId; + } + + if (reader.TryGetString(index++, out var presentationUniqueKey)) + { + entity.PresentationUniqueKey = presentationUniqueKey; + } + + if (reader.TryGetInt32(index++, out var parentalRating)) + { + entity.InheritedParentalRatingValue = parentalRating; + } + + if (reader.TryGetString(index++, out var externalSeriesId)) + { + entity.ExternalSeriesId = externalSeriesId; + } + + if (reader.TryGetString(index++, out var tagLine)) + { + entity.Tagline = tagLine; + } + + if (reader.TryGetString(index++, out var providerIds)) + { + entity.Provider = providerIds.Split('|').Select(e => e.Split("=")) + .Select(e => new BaseItemProvider() + { + Item = null!, + ProviderId = e[0], + ProviderValue = e[1] + }).ToArray(); + } + + if (reader.TryGetString(index++, out var imageInfos)) + { + entity.Images = DeserializeImages(imageInfos).Select(f => Map(entity.Id, f)).ToArray(); + } + + if (reader.TryGetString(index++, out var productionLocations)) + { + entity.ProductionLocations = productionLocations; + } + + if (reader.TryGetString(index++, out var extraIds)) + { + entity.ExtraIds = extraIds; + } + + if (reader.TryGetInt32(index++, out var totalBitrate)) + { + entity.TotalBitrate = totalBitrate; + } + + if (reader.TryGetString(index++, out var extraTypeString) && Enum.TryParse<BaseItemExtraType>(extraTypeString, out var extraType)) + { + entity.ExtraType = extraType; + } + + if (reader.TryGetString(index++, out var artists)) + { + entity.Artists = artists; + } + + if (reader.TryGetString(index++, out var albumArtists)) + { + entity.AlbumArtists = albumArtists; + } + + if (reader.TryGetString(index++, out var externalId)) + { + entity.ExternalId = externalId; + } + + if (reader.TryGetString(index++, out var seriesPresentationUniqueKey)) + { + entity.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; + } + + if (reader.TryGetString(index++, out var showId)) + { + entity.ShowId = showId; + } + + if (reader.TryGetString(index++, out var ownerId)) + { + entity.OwnerId = ownerId; + } + + if (reader.TryGetString(index++, out var mediaType)) + { + entity.MediaType = mediaType; + } + + var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false); + var dataKeys = baseItem.GetUserDataKeys(); + userDataKeys.AddRange(dataKeys); + + return (entity, userDataKeys.ToArray()); + } + + private static BaseItemImageInfo Map(Guid baseItemId, ItemImageInfo e) + { + return new BaseItemImageInfo() + { + ItemId = baseItemId, + Id = Guid.NewGuid(), + Path = e.Path, + Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null, + DateModified = e.DateModified, + Height = e.Height, + Width = e.Width, + ImageType = (ImageInfoImageType)e.Type, + Item = null! + }; + } + + internal ItemImageInfo[] DeserializeImages(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty<ItemImageInfo>(); + } + + // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed + var valueSpan = value.AsSpan(); + var count = valueSpan.Count('|') + 1; + + var position = 0; + var result = new ItemImageInfo[count]; + foreach (var part in valueSpan.Split('|')) + { + var image = ItemImageInfoFromValueString(part); + + if (image is not null) + { + result[position++] = image; + } + } + + if (position == count) + { + return result; + } + + if (position == 0) + { + return Array.Empty<ItemImageInfo>(); + } + + // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array. + return result[..position]; + } + + internal ItemImageInfo? ItemImageInfoFromValueString(ReadOnlySpan<char> value) + { + const char Delimiter = '*'; + + var nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan<char> path = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan<char> dateModified = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan<char> imageType = value[..nextSegment]; + + var image = new ItemImageInfo + { + Path = path.ToString() + }; + + if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks) + && ticks >= DateTime.MinValue.Ticks + && ticks <= DateTime.MaxValue.Ticks) + { + image.DateModified = new DateTime(ticks, DateTimeKind.Utc); + } + else + { + return null; + } + + if (Enum.TryParse(imageType, true, out ImageType type)) + { + image.Type = type; + } + else + { + return null; + } + + // Optional parameters: width*height*blurhash + if (nextSegment + 1 < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1 || nextSegment == value.Length) + { + return image; + } + + ReadOnlySpan<char> widthSpan = value[..nextSegment]; + + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan<char> heightSpan = value[..nextSegment]; + + if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) + && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) + { + image.Width = width; + image.Height = height; + } + + if (nextSegment < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + var length = value.Length; + + Span<char> blurHashSpan = stackalloc char[length]; + for (int i = 0; i < length; i++) + { + var c = value[i]; + blurHashSpan[i] = c switch + { + '/' => Delimiter, + '\\' => '|', + _ => c + }; + } + + image.BlurHash = new string(blurHashSpan); + } + } + + return image; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index 247e1d845..9c2184029 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -30,7 +30,7 @@ namespace Jellyfin.Server.Migrations.Routines } /// <inheritdoc/> - public Guid Id => Guid.Parse("{67445D54-B895-4B24-9F4C-35CE0690EA07}"); + public Guid Id => Guid.Parse("{73DAB92A-178B-48CD-B05B-FE18733ACDC8}"); /// <inheritdoc/> public string Name => "MigrateRatingLevels"; diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs index 301541b6c..c1a9e8894 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs @@ -1,10 +1,15 @@ using System; +using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Routines; @@ -16,6 +21,7 @@ public class MoveTrickplayFiles : IMigrationRoutine private readonly ITrickplayManager _trickplayManager; private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; + private readonly ILogger<MoveTrickplayFiles> _logger; /// <summary> /// Initializes a new instance of the <see cref="MoveTrickplayFiles"/> class. @@ -23,11 +29,13 @@ public class MoveTrickplayFiles : IMigrationRoutine /// <param name="trickplayManager">Instance of the <see cref="ITrickplayManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public MoveTrickplayFiles(ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager) + /// <param name="logger">The logger.</param> + public MoveTrickplayFiles(ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILogger<MoveTrickplayFiles> logger) { _trickplayManager = trickplayManager; _fileSystem = fileSystem; _libraryManager = libraryManager; + _logger = logger; } /// <inheritdoc /> @@ -42,26 +50,49 @@ public class MoveTrickplayFiles : IMigrationRoutine /// <inheritdoc /> public void Perform() { - var trickplayItems = _trickplayManager.GetTrickplayItemsAsync().GetAwaiter().GetResult(); - foreach (var itemId in trickplayItems) + const int Limit = 100; + int itemCount = 0, offset = 0, previousCount; + + var sw = Stopwatch.StartNew(); + var trickplayQuery = new InternalItemsQuery { - var resolutions = _trickplayManager.GetTrickplayResolutions(itemId).GetAwaiter().GetResult(); - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - continue; - } + MediaTypes = [MediaType.Video], + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + IsFolder = false + }; - foreach (var resolution in resolutions) + do + { + var trickplayInfos = _trickplayManager.GetTrickplayItemsAsync(Limit, offset).GetAwaiter().GetResult(); + previousCount = trickplayInfos.Count; + offset += Limit; + + trickplayQuery.ItemIds = trickplayInfos.Select(i => i.ItemId).Distinct().ToArray(); + var items = _libraryManager.GetItemList(trickplayQuery); + foreach (var trickplayInfo in trickplayInfos) { - var oldPath = GetOldTrickplayDirectory(item, resolution.Key); - var newPath = _trickplayManager.GetTrickplayDirectory(item, resolution.Value.TileWidth, resolution.Value.TileHeight, resolution.Value.Width, false); + var item = items.OfType<Video>().FirstOrDefault(i => i.Id.Equals(trickplayInfo.ItemId)); + if (item is null) + { + continue; + } + + if (++itemCount % 1_000 == 0) + { + _logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed); + } + + var oldPath = GetOldTrickplayDirectory(item, trickplayInfo.Width); + var newPath = _trickplayManager.GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false); if (_fileSystem.DirectoryExists(oldPath)) { _fileSystem.MoveDirectory(oldPath, newPath); } } - } + } while (previousCount == Limit); + + _logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed); } private string GetOldTrickplayDirectory(BaseItem item, int? width = null) diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs new file mode 100644 index 000000000..f84bccc25 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Threading; + +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Remove duplicate playlist entries. +/// </summary> +internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine +{ + private readonly ILogger<RemoveDuplicatePlaylistChildren> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IPlaylistManager _playlistManager; + + public RemoveDuplicatePlaylistChildren( + ILogger<RemoveDuplicatePlaylistChildren> logger, + ILibraryManager libraryManager, + IPlaylistManager playlistManager) + { + _logger = logger; + _libraryManager = libraryManager; + _playlistManager = playlistManager; + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("{96C156A2-7A13-4B3B-A8B8-FB80C94D20C0}"); + + /// <inheritdoc/> + public string Name => "RemoveDuplicatePlaylistChildren"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; + + /// <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 nullItemChildren = linkedChildren.Where(c => c.ItemId is null); + var deduplicatedChildren = linkedChildren.DistinctBy(c => c.ItemId); + var newLinkedChildren = nullItemChildren.Concat(deduplicatedChildren); + playlist.LinkedChildren = linkedChildren; + playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + _playlistManager.SavePlaylistFile(playlist); + } + } + } + } +} diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 295fb8112..3f73c15b4 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -13,6 +13,7 @@ using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using Microsoft.AspNetCore.Hosting; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -193,6 +194,7 @@ namespace Jellyfin.Server // Don't throw additional exception if startup failed. if (appHost.ServiceProvider is not null) { + var isSqlite = false; _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); // Run before disposing the application var context = await appHost.ServiceProvider.GetRequiredService<IDbContextFactory<JellyfinDbContext>>().CreateDbContextAsync().ConfigureAwait(false); @@ -200,9 +202,15 @@ namespace Jellyfin.Server { if (context.Database.IsSqlite()) { + isSqlite = true; await context.Database.ExecuteSqlRawAsync("PRAGMA optimize").ConfigureAwait(false); } } + + if (isSqlite) + { + SqliteConnection.ClearAllPools(); + } } host?.Dispose(); diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index e9fb3e4c2..c68661469 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -129,7 +129,6 @@ namespace Jellyfin.Server services.AddHostedService<RecordingsHost>(); services.AddHostedService<AutoDiscoveryHost>(); - services.AddHostedService<PortForwardingHost>(); services.AddHostedService<NfoUserDataSaver>(); services.AddHostedService<LibraryChangedNotifier>(); services.AddHostedService<UserDataChangeNotifier>(); |
