From f73fc1feb2f2204ac8c27b1162ec0724529f2d7c Mon Sep 17 00:00:00 2001 From: JPVenson Date: Fri, 15 May 2026 19:03:08 +0000 Subject: Update filenaming scheme to match EFCore one --- .../20250420050000_DisableTranscodingThrottling.cs | 38 + .../20250420060000_CreateUserLoggingConfigFile.cs | 76 + .../20250420070000_MigrateActivityLogDb.cs | 163 +++ .../20250420080000_RemoveDuplicateExtras.cs | 76 + .../20250420090000_AddDefaultPluginRepository.cs | 39 + .../Routines/20250420100000_MigrateUserDb.cs | 233 +++ .../20250420110000_ReaddDefaultPluginRepository.cs | 42 + .../20250420120000_MigrateDisplayPreferencesDb.cs | 242 ++++ ...20250420130000_RemoveDownloadImagesInAdvance.cs | 45 + .../20250420140000_MigrateAuthenticationDb.cs | 162 +++ .../Routines/20250420150000_FixPlaylistOwner.cs | 70 + .../20250420160000_AddDefaultCastReceivers.cs | 45 + ...20250420170000_UpdateDefaultPluginRepository.cs | 46 + .../Routines/20250420180000_FixAudioData.cs | 76 + ...250420190000_RemoveDuplicatePlaylistChildren.cs | 61 + ...420193000_MigrateLibraryDbCompatibilityCheck.cs | 73 + .../Routines/20250420200000_MigrateLibraryDb.cs | 1502 ++++++++++++++++++++ .../Routines/20250420210000_MoveExtractedFiles.cs | 329 +++++ .../Routines/20250420230000_MoveTrickplayFiles.cs | 130 ++ .../20250420230000_RefreshInternalDateModified.cs | 131 ++ .../Routines/20250421000000_MigrateKeyframeData.cs | 169 +++ .../20250618010000_MigrateLibraryUserData.cs | 123 ++ .../Migrations/Routines/20250620180000_FixDates.cs | 171 +++ .../Routines/20250730215000_ReseedFolderFlag.cs | 74 + .../Routines/20251008120000_RefreshCleanNames.cs | 102 ++ .../Routines/20251009200000_CleanMusicArtist.cs | 47 + .../20260113120000_MigrateLinkedChildren.cs | 615 ++++++++ .../20260113230000_CleanupOrphanedExtras.cs | 112 ++ ...60115120000_FixIncorrectOwnerIdRelationships.cs | 341 +++++ ...06200000_FixLibrarySubtitleDownloadLanguages.cs | 105 ++ .../Routines/20260302090000_MigrateRatingLevels.cs | 68 + .../20260508120000_MergeDuplicateMusicArtists.cs | 204 +++ .../20260508130000_MergeDuplicatePeople.cs | 300 ++++ .../Migrations/Routines/AddDefaultCastReceivers.cs | 45 - .../Routines/AddDefaultPluginRepository.cs | 39 - .../Migrations/Routines/CleanMusicArtist.cs | 47 - .../Migrations/Routines/CleanupOrphanedExtras.cs | 112 -- .../Routines/CreateUserLoggingConfigFile.cs | 76 - .../Routines/DisableTranscodingThrottling.cs | 38 - .../Migrations/Routines/FixAudioData.cs | 76 - Jellyfin.Server/Migrations/Routines/FixDates.cs | 171 --- .../Routines/FixIncorrectOwnerIdRelationships.cs | 341 ----- .../FixLibrarySubtitleDownloadLanguages.cs | 105 -- .../Migrations/Routines/FixPlaylistOwner.cs | 70 - .../Routines/MergeDuplicateMusicArtists.cs | 204 --- .../Migrations/Routines/MergeDuplicatePeople.cs | 294 ---- .../Migrations/Routines/MigrateActivityLogDb.cs | 163 --- .../Migrations/Routines/MigrateAuthenticationDb.cs | 162 --- .../Routines/MigrateDisplayPreferencesDb.cs | 242 ---- .../Migrations/Routines/MigrateKeyframeData.cs | 169 --- .../Migrations/Routines/MigrateLibraryDb.cs | 1502 -------------------- .../Routines/MigrateLibraryDbCompatibilityCheck.cs | 73 - .../Migrations/Routines/MigrateLibraryUserData.cs | 123 -- .../Migrations/Routines/MigrateLinkedChildren.cs | 615 -------- .../Migrations/Routines/MigrateRatingLevels.cs | 68 - .../Migrations/Routines/MigrateUserDb.cs | 233 --- .../Migrations/Routines/MoveExtractedFiles.cs | 329 ----- .../Migrations/Routines/MoveTrickplayFiles.cs | 130 -- .../Routines/ReaddDefaultPluginRepository.cs | 42 - .../Migrations/Routines/RefreshCleanNames.cs | 102 -- .../Routines/RefreshInternalDateModified.cs | 131 -- .../Routines/RemoveDownloadImagesInAdvance.cs | 45 - .../Migrations/Routines/RemoveDuplicateExtras.cs | 76 - .../Routines/RemoveDuplicatePlaylistChildren.cs | 61 - .../Migrations/Routines/ReseedFolderFlag.cs | 74 - .../Routines/UpdateDefaultPluginRepository.cs | 46 - 66 files changed, 6010 insertions(+), 6004 deletions(-) create mode 100644 Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20251008120000_RefreshCleanNames.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/FixAudioData.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/FixDates.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs delete mode 100644 Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs new file mode 100644 index 0000000000..acf2835fe0 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs @@ -0,0 +1,38 @@ +using System; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// Disable transcode throttling for all installations since it is currently broken for certain video formats. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [JellyfinMigration("2025-04-20T05:00:00", nameof(DisableTranscodingThrottling), "4124C2CD-E939-4FFB-9BE9-9B311C413638")] + internal class DisableTranscodingThrottling : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete + { + private readonly ILogger _logger; + private readonly IConfigurationManager _configManager; + + public DisableTranscodingThrottling(ILogger logger, IConfigurationManager configManager) + { + _logger = logger; + _configManager = configManager; + } + + /// + public void Perform() + { + // Set EnableThrottling to false since it wasn't used before and may introduce issues + var encoding = _configManager.GetEncodingOptions(); + if (encoding.EnableThrottling) + { + _logger.LogInformation("Disabling transcoding throttling during migration"); + encoding.EnableThrottling = false; + + _configManager.SaveConfiguration("encoding", encoding); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs new file mode 100644 index 0000000000..1326a6dc8d --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MediaBrowser.Common.Configuration; +using Newtonsoft.Json.Linq; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// Migration to initialize the user logging configuration file "logging.user.json". + /// If the deprecated logging.json file exists and has a custom config, it will be used as logging.user.json, + /// otherwise a blank file will be created. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [JellyfinMigration("2025-04-20T06:00:00", nameof(CreateUserLoggingConfigFile), "EF103419-8451-40D8-9F34-D1A8E93A1679")] + internal class CreateUserLoggingConfigFile : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete + { + /// + /// File history for logging.json as existed during this migration creation. The contents for each has been minified. + /// + private readonly List _defaultConfigHistory = new List + { + // 9a6c27947353585391e211aa88b925f81e8cd7b9 + @"{""Serilog"":{""MinimumLevel"":{""Default"":""Information"",""Override"":{""Microsoft"":""Warning"",""System"":""Warning""}},""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}", + // 71bdcd730705a714ee208eaad7290b7c68df3885 + @"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}", + // a44936f97f8afc2817d3491615a7cfe1e31c251c + @"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}""}},{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}""}}]}}", + // 7af3754a11ad5a4284f107997fb5419a010ce6f3 + @"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}""}}]}}]}}", + // 60691349a11f541958e0b2247c9abc13cb40c9fb + @"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message}{NewLine}{Exception}""}}]}}]}}", + // 65fe243afbcc4b596cf8726708c1965cd34b5f68 + @"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] {ThreadId} {SourceContext}: {Message:lj} {NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {ThreadId} {SourceContext}:{Message} {NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}", + // 96c9af590494aa8137d5a061aaf1e68feee60b67 + @"{""Serilog"":{""MinimumLevel"":""Information"",""WriteTo"":[{""Name"":""Console"",""Args"":{""outputTemplate"":""[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}""}},{""Name"":""Async"",""Args"":{""configure"":[{""Name"":""File"",""Args"":{""path"":""%JELLYFIN_LOG_DIR%//log_.log"",""rollingInterval"":""Day"",""retainedFileCountLimit"":3,""rollOnFileSizeLimit"":true,""fileSizeLimitBytes"":100000000,""outputTemplate"":""[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}:{Message}{NewLine}{Exception}""}}]}}],""Enrich"":[""FromLogContext"",""WithThreadId""]}}", + }; + + private readonly IApplicationPaths _appPaths; + + public CreateUserLoggingConfigFile(IApplicationPaths appPaths) + { + _appPaths = appPaths; + } + + /// + public void Perform() + { + var logDirectory = _appPaths.ConfigurationDirectoryPath; + var existingConfigPath = Path.Combine(logDirectory, "logging.json"); + + // If the existing logging.json config file is unmodified, then 'reset' it by moving it to 'logging.old.json' + // NOTE: This config file has 'reloadOnChange: true', so this change will take effect immediately even though it has already been loaded + if (File.Exists(existingConfigPath) && ExistingConfigUnmodified(existingConfigPath)) + { + File.Move(existingConfigPath, Path.Combine(logDirectory, "logging.old.json")); + } + } + + /// + /// Check if the existing logging.json file has not been modified by the user by comparing it to all the + /// versions in our git history. Until now, the file has never been migrated after first creation so users + /// could have any version from the git history. + /// + /// does not exist or could not be read. + private bool ExistingConfigUnmodified(string oldConfigPath) + { + var existingConfigJson = JToken.Parse(File.ReadAllText(oldConfigPath)); + return _defaultConfigHistory + .Select(JToken.Parse) + .Any(historicalConfigJson => JToken.DeepEquals(existingConfigJson, historicalConfigJson)); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs new file mode 100644 index 0000000000..8c8563190d --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// The migration routine for migrating the activity log database to EF Core. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [JellyfinMigration("2025-04-20T07:00:00", nameof(MigrateActivityLogDb), "3793eb59-bc8c-456c-8b9f-bd5a62a42978")] + public class MigrateActivityLogDb : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete + { + private const string DbFilename = "activitylog.db"; + + private readonly ILogger _logger; + private readonly IDbContextFactory _provider; + private readonly IServerApplicationPaths _paths; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The server application paths. + /// The database provider. + public MigrateActivityLogDb(ILogger logger, IServerApplicationPaths paths, IDbContextFactory provider) + { + _logger = logger; + _provider = provider; + _paths = paths; + } + + /// + public void Perform() + { + var logLevelDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "None", LogLevel.None }, + { "Trace", LogLevel.Trace }, + { "Debug", LogLevel.Debug }, + { "Information", LogLevel.Information }, + { "Info", LogLevel.Information }, + { "Warn", LogLevel.Warning }, + { "Warning", LogLevel.Warning }, + { "Error", LogLevel.Error }, + { "Critical", LogLevel.Critical } + }; + + var dataPath = _paths.DataPath; + var activityLogPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(activityLogPath)) + { + _logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath); + return; + } + + using (var connection = new SqliteConnection($"Filename={activityLogPath}")) + { + connection.Open(); + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath); + return; + } + } + + using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}"); + userDbConnection.Open(); + _logger.LogWarning("Migrating the activity database may take a while, do not stop Jellyfin."); + using var dbContext = _provider.CreateDbContext(); + + // Make sure that the database is empty in case of failed migration due to power outages, etc. + dbContext.ActivityLogs.RemoveRange(dbContext.ActivityLogs); + dbContext.SaveChanges(); + // Reset the autoincrement counter + dbContext.Database.ExecuteSqlRaw("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'ActivityLog';"); + dbContext.SaveChanges(); + + var newEntries = new List(); + + var queryResult = connection.Query("SELECT * FROM ActivityLog ORDER BY Id"); + + foreach (var entry in queryResult) + { + if (!logLevelDictionary.TryGetValue(entry.GetString(8), out var severity)) + { + severity = LogLevel.Trace; + } + + var guid = Guid.Empty; + if (!entry.IsDBNull(6) && !entry.TryGetGuid(6, out guid)) + { + var id = entry.GetString(6); + // This is not a valid Guid, see if it is an internal ID from an old Emby schema + _logger.LogWarning("Invalid Guid in UserId column: {Guid}", id); + + using var statement = userDbConnection.PrepareStatement("SELECT guid FROM LocalUsersv2 WHERE Id=@Id"); + statement.TryBind("@Id", id); + + using var reader = statement.ExecuteReader(); + if (reader.HasRows && reader.Read() && reader.TryGetGuid(0, out guid)) + { + // Successfully parsed a Guid from the user table. + break; + } + } + + var newEntry = new ActivityLog(entry.GetString(1), entry.GetString(4), guid) + { + DateCreated = entry.GetDateTime(7), + LogSeverity = severity + }; + + if (entry.TryGetString(2, out var result)) + { + newEntry.Overview = result; + } + + if (entry.TryGetString(3, out result)) + { + newEntry.ShortOverview = result; + } + + if (entry.TryGetString(5, out result)) + { + newEntry.ItemId = result; + } + + newEntries.Add(newEntry); + } + + dbContext.ActivityLogs.AddRange(newEntries); + dbContext.SaveChanges(); + } + + try + { + File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old")); + + var journalPath = Path.Combine(dataPath, DbFilename + "-journal"); + if (File.Exists(journalPath)) + { + File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal")); + } + } + catch (IOException e) + { + _logger.LogError(e, "Error renaming legacy activity log database to 'activitylog.db.old'"); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs new file mode 100644 index 0000000000..c9e66d0cfe --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs @@ -0,0 +1,76 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using Emby.Server.Implementations.Data; +using MediaBrowser.Controller; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2025-04-20T08:00:00", nameof(RemoveDuplicateExtras), "ACBE17B7-8435-4A83-8B64-6FCF162CB9BD")] +internal class RemoveDuplicateExtras : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete +{ + private const string DbFilename = "library.db"; + private readonly ILogger _logger; + private readonly IServerApplicationPaths _paths; + + public RemoveDuplicateExtras(ILogger logger, IServerApplicationPaths paths) + { + _logger = logger; + _paths = paths; + } + + /// + public void Perform() + { + var dataPath = _paths.DataPath; + var dbPath = Path.Combine(dataPath, DbFilename); + using var connection = new SqliteConnection($"Filename={dbPath}"); + connection.Open(); + using (var transaction = connection.BeginTransaction()) + { + // Query the database for the ids of duplicate extras + var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'"); + var bads = string.Join(", ", queryResult.Select(x => x.GetString(0))); + + // Do nothing if no duplicate extras were detected + if (bads.Length == 0) + { + _logger.LogInformation("No duplicate extras detected, skipping migration."); + return; + } + + // Back up the database before deleting any entries + for (int i = 1; ; i++) + { + var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i); + if (!File.Exists(bakPath)) + { + try + { + File.Copy(dbPath, bakPath); + _logger.LogInformation("Library database backed up to {BackupPath}", bakPath); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath); + throw; + } + } + } + + // Delete all duplicate extras + _logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads); + connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')"); + transaction.Commit(); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs new file mode 100644 index 0000000000..8c8398a161 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs @@ -0,0 +1,39 @@ +using System; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Updates; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// Migration to initialize system configuration with the default plugin repository. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [JellyfinMigration("2025-04-20T09:00:00", nameof(AddDefaultPluginRepository), "EB58EBEE-9514-4B9B-8225-12E1A40020DF", RunMigrationOnSetup = true)] + public class AddDefaultPluginRepository : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete + { + private readonly IServerConfigurationManager _serverConfigurationManager; + + private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo + { + Name = "Jellyfin Stable", + Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json" + }; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public AddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// + public void Perform() + { + _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo }; + _serverConfigurationManager.SaveConfiguration(); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs new file mode 100644 index 0000000000..8c3361ee16 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs @@ -0,0 +1,233 @@ +using System; +using System.IO; +using Emby.Server.Implementations.Data; +using Jellyfin.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions.Json; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Users; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// The migration routine for migrating the user database to EF Core. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2025-04-20T10:00:00", nameof(MigrateUserDb), "5C4B82A2-F053-4009-BD05-B6FCAD82F14C")] +public class MigrateUserDb : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete +{ + private const string DbFilename = "users.db"; + + private readonly ILogger _logger; + private readonly IServerApplicationPaths _paths; + private readonly IDbContextFactory _provider; + private readonly IXmlSerializer _xmlSerializer; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The server application paths. + /// The database provider. + /// The xml serializer. + public MigrateUserDb( + ILogger logger, + IServerApplicationPaths paths, + IDbContextFactory provider, + IXmlSerializer xmlSerializer) + { + _logger = logger; + _paths = paths; + _provider = provider; + _xmlSerializer = xmlSerializer; + } + + /// + public void Perform() + { + var dataPath = _paths.DataPath; + var userDbPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(userDbPath)) + { + _logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath); + return; + } + + _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin."); + + using (var connection = new SqliteConnection($"Filename={userDbPath}")) + { + connection.Open(); + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath); + return; + } + } + + using var dbContext = _provider.CreateDbContext(); + + var queryResult = connection.Query("SELECT * FROM LocalUsersv2"); + + dbContext.RemoveRange(dbContext.Users); + dbContext.SaveChanges(); + + foreach (var entry in queryResult) + { + UserMockup? mockup = JsonSerializer.Deserialize(entry.GetStream(2), JsonDefaults.Options); + if (mockup is null) + { + continue; + } + + var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name); + + var configPath = Path.Combine(userDataDir, "config.xml"); + var config = File.Exists(configPath) + ? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration() + : new UserConfiguration(); + + var policyPath = Path.Combine(userDataDir, "policy.xml"); + var policy = File.Exists(policyPath) + ? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy() + : new UserPolicy(); + policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace( + "Emby.Server.Implementations.Library", + "Jellyfin.Server.Implementations.Users", + StringComparison.Ordinal) + ?? typeof(DefaultAuthenticationProvider).FullName; + + policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName; + int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch + { + -1 => null, + 0 => 3, + _ => policy.LoginAttemptsBeforeLockout + }; + + var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!) + { + Id = entry.GetGuid(1), + InternalId = entry.GetInt64(0), + MaxParentalRatingScore = policy.MaxParentalRating, + MaxParentalRatingSubScore = null, + EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess, + RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit, + InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount, + LoginAttemptsBeforeLockout = maxLoginAttempts, + SubtitleMode = config.SubtitleMode, + HidePlayedInLatest = config.HidePlayedInLatest, + EnableLocalPassword = config.EnableLocalPassword, + PlayDefaultAudioTrack = config.PlayDefaultAudioTrack, + DisplayCollectionsView = config.DisplayCollectionsView, + DisplayMissingEpisodes = config.DisplayMissingEpisodes, + AudioLanguagePreference = config.AudioLanguagePreference, + RememberAudioSelections = config.RememberAudioSelections, + EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay, + RememberSubtitleSelections = config.RememberSubtitleSelections, + SubtitleLanguagePreference = config.SubtitleLanguagePreference, + Password = mockup.Password, + LastLoginDate = mockup.LastLoginDate, + LastActivityDate = mockup.LastActivityDate + }; + + if (mockup.ImageInfos.Length > 0) + { + ItemImageInfo info = mockup.ImageInfos[0]; + + user.ProfileImage = new ImageInfo(info.Path) + { + LastModified = info.DateModified + }; + } + + user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); + user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); + user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); + user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); + user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); + user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); + user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); + user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); + user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); + user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); + user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); + user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); + user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); + user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); + user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); + user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); + user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); + user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); + user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); + + foreach (var policyAccessSchedule in policy.AccessSchedules) + { + user.AccessSchedules.Add(policyAccessSchedule); + } + + user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); + user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); + user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); + user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); + user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); + + dbContext.Users.Add(user); + } + + dbContext.SaveChanges(); + } + + try + { + File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old")); + + var journalPath = Path.Combine(dataPath, DbFilename + "-journal"); + if (File.Exists(journalPath)) + { + File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal")); + } + } + catch (IOException e) + { + _logger.LogError(e, "Error renaming legacy user database to 'users.db.old'"); + } + } + +#nullable disable + internal class UserMockup + { + public string Password { get; set; } + + public string EasyPassword { get; set; } + + public DateTime? LastLoginDate { get; set; } + + public DateTime? LastActivityDate { get; set; } + + public string Name { get; set; } + + public ItemImageInfo[] ImageInfos { get; set; } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs new file mode 100644 index 0000000000..ebf4a2780e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs @@ -0,0 +1,42 @@ +using System; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Updates; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migration to initialize system configuration with the default plugin repository. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2025-04-20T11:00:00", nameof(ReaddDefaultPluginRepository), "5F86E7F6-D966-4C77-849D-7A7B40B68C4E", RunMigrationOnSetup = true)] +public class ReaddDefaultPluginRepository : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete +{ + private readonly IServerConfigurationManager _serverConfigurationManager; + + private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo + { + Name = "Jellyfin Stable", + Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json" + }; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public ReaddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// + public void Perform() + { + // Only add if repository list is empty + if (_serverConfigurationManager.Configuration.PluginRepositories.Length == 0) + { + _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo }; + _serverConfigurationManager.SaveConfiguration(); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs new file mode 100644 index 0000000000..ffd06fea0d --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// The migration routine for migrating the display preferences database to EF Core. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [JellyfinMigration("2025-04-20T12:00:00", nameof(MigrateDisplayPreferencesDb), "06387815-C3CC-421F-A888-FB5F9992BEA8")] + public class MigrateDisplayPreferencesDb : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete + { + private const string DbFilename = "displaypreferences.db"; + + private readonly ILogger _logger; + private readonly IServerApplicationPaths _paths; + private readonly IDbContextFactory _provider; + private readonly JsonSerializerOptions _jsonOptions; + private readonly IUserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The server application paths. + /// The database provider. + /// The user manager. + public MigrateDisplayPreferencesDb( + ILogger logger, + IServerApplicationPaths paths, + IDbContextFactory provider, + IUserManager userManager) + { + _logger = logger; + _paths = paths; + _provider = provider; + _userManager = userManager; + _jsonOptions = new JsonSerializerOptions(); + _jsonOptions.Converters.Add(new JsonStringEnumConverter()); + } + + /// + public void Perform() + { + HomeSectionType[] defaults = + { + HomeSectionType.SmallLibraryTiles, + HomeSectionType.Resume, + HomeSectionType.ResumeAudio, + HomeSectionType.LiveTv, + HomeSectionType.NextUp, + HomeSectionType.LatestMedia, + HomeSectionType.None, + }; + + var chromecastDict = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "stable", ChromecastVersion.Stable }, + { "nightly", ChromecastVersion.Unstable }, + { "unstable", ChromecastVersion.Unstable } + }; + + var displayPrefs = new HashSet(StringComparer.OrdinalIgnoreCase); + var customDisplayPrefs = new HashSet(StringComparer.OrdinalIgnoreCase); + var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); + + if (!File.Exists(dbFilePath)) + { + _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath); + return; + } + + using (var connection = new SqliteConnection($"Filename={dbFilePath}")) + { + connection.Open(); + + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath); + return; + } + } + + using var dbContext = _provider.CreateDbContext(); + + var results = connection.Query("SELECT * FROM userdisplaypreferences"); + foreach (var result in results) + { + var dto = JsonSerializer.Deserialize(result.GetStream(3), _jsonOptions); + if (dto is null) + { + continue; + } + + var itemId = result.GetGuid(1); + var dtoUserId = itemId; + var client = result.GetString(2); + var displayPreferencesKey = $"{dtoUserId}|{itemId}|{client}"; + if (displayPrefs.Contains(displayPreferencesKey)) + { + // Duplicate display preference. + continue; + } + + displayPrefs.Add(displayPreferencesKey); + var existingUser = _userManager.GetUserById(dtoUserId); + if (existingUser is null) + { + _logger.LogWarning("User with ID {UserId} does not exist in the database, skipping migration.", dtoUserId); + continue; + } + + var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version) + && !string.IsNullOrEmpty(version) + ? chromecastDict[version] + : ChromecastVersion.Stable; + dto.CustomPrefs.Remove("chromecastVersion"); + + var displayPreferences = new DisplayPreferences(dtoUserId, itemId, client) + { + IndexBy = Enum.TryParse(dto.IndexBy, true, out var indexBy) ? indexBy : null, + ShowBackdrop = dto.ShowBackdrop, + ShowSidebar = dto.ShowSidebar, + ScrollDirection = dto.ScrollDirection, + ChromecastVersion = chromecastVersion, + SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length) && int.TryParse(length, out var skipForwardLength) + ? skipForwardLength + : 30000, + SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) && int.TryParse(length, out var skipBackwardLength) + ? skipBackwardLength + : 10000, + EnableNextVideoInfoOverlay = !dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled) || string.IsNullOrEmpty(enabled) || bool.Parse(enabled), + DashboardTheme = dto.CustomPrefs.TryGetValue("dashboardtheme", out var theme) ? theme : string.Empty, + TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty + }; + + dto.CustomPrefs.Remove("skipForwardLength"); + dto.CustomPrefs.Remove("skipBackLength"); + dto.CustomPrefs.Remove("enableNextVideoInfoOverlay"); + dto.CustomPrefs.Remove("dashboardtheme"); + dto.CustomPrefs.Remove("tvhome"); + + for (int i = 0; i < 7; i++) + { + var key = "homesection" + i; + dto.CustomPrefs.TryGetValue(key, out var homeSection); + + displayPreferences.HomeSections.Add(new HomeSection + { + Order = i, + Type = Enum.TryParse(homeSection, true, out var type) ? type : defaults[i] + }); + + dto.CustomPrefs.Remove(key); + } + + var defaultLibraryPrefs = new ItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client) + { + SortBy = dto.SortBy ?? "SortName", + SortOrder = dto.SortOrder, + RememberIndexing = dto.RememberIndexing, + RememberSorting = dto.RememberSorting, + }; + + dbContext.Add(defaultLibraryPrefs); + + foreach (var key in dto.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.Ordinal))) + { + if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var landingItemId)) + { + continue; + } + + var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, landingItemId, displayPreferences.Client) + { + SortBy = dto.SortBy ?? "SortName", + SortOrder = dto.SortOrder, + RememberIndexing = dto.RememberIndexing, + RememberSorting = dto.RememberSorting, + }; + + if (Enum.TryParse(dto.ViewType, true, out var viewType)) + { + libraryDisplayPreferences.ViewType = viewType; + } + + dto.CustomPrefs.Remove(key); + dbContext.ItemDisplayPreferences.Add(libraryDisplayPreferences); + } + + foreach (var (key, value) in dto.CustomPrefs) + { + // Custom display preferences can have a key collision. + var indexKey = $"{displayPreferences.UserId}|{itemId}|{displayPreferences.Client}|{key}"; + if (!customDisplayPrefs.Contains(indexKey)) + { + dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value)); + customDisplayPrefs.Add(indexKey); + } + } + + dbContext.Add(displayPreferences); + } + + dbContext.SaveChanges(); + } + + try + { + File.Move(dbFilePath, dbFilePath + ".old"); + + var journalPath = dbFilePath + "-journal"; + if (File.Exists(journalPath)) + { + File.Move(journalPath, dbFilePath + ".old-journal"); + } + } + catch (IOException e) + { + _logger.LogError(e, "Error renaming legacy display preferences database to 'displaypreferences.db.old'"); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs new file mode 100644 index 0000000000..b626c473e3 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs @@ -0,0 +1,45 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Removes the old 'RemoveDownloadImagesInAdvance' from library options. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2025-04-20T13:00:00", nameof(RemoveDownloadImagesInAdvance), "A81F75E0-8F43-416F-A5E8-516CCAB4D8CC")] +internal class RemoveDownloadImagesInAdvance : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete +{ + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + + public RemoveDownloadImagesInAdvance(ILogger logger, ILibraryManager libraryManager) + { + _logger = logger; + _libraryManager = libraryManager; + } + + /// + public void Perform() + { + var virtualFolders = _libraryManager.GetVirtualFolders(false); + _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries"); + foreach (var virtualFolder in virtualFolders) + { + // Some virtual folders don't have a proper item id. + if (!Guid.TryParse(virtualFolder.ItemId, out var folderId)) + { + continue; + } + + var libraryOptions = virtualFolder.LibraryOptions; + var collectionFolder = _libraryManager.GetItemById(folderId) ?? throw new InvalidOperationException("Failed to find CollectionFolder"); + // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed. + collectionFolder.UpdateLibraryOptions(libraryOptions); + _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs new file mode 100644 index 0000000000..0de775e03a --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities.Security; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// A migration that moves data from the authentication database into the new schema. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [JellyfinMigration("2025-04-20T14:00:00", nameof(MigrateAuthenticationDb), "5BD72F41-E6F3-4F60-90AA-09869ABE0E22")] + public class MigrateAuthenticationDb : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete + { + private const string DbFilename = "authentication.db"; + + private readonly ILogger _logger; + private readonly IDbContextFactory _dbProvider; + private readonly IServerApplicationPaths _appPaths; + private readonly IUserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The database provider. + /// The server application paths. + /// The user manager. + public MigrateAuthenticationDb( + ILogger logger, + IDbContextFactory dbProvider, + IServerApplicationPaths appPaths, + IUserManager userManager) + { + _logger = logger; + _dbProvider = dbProvider; + _appPaths = appPaths; + _userManager = userManager; + } + + /// + public void Perform() + { + var dataPath = _appPaths.DataPath; + var dbFilePath = Path.Combine(dataPath, DbFilename); + + if (!File.Exists(dbFilePath)) + { + _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath); + return; + } + + using (var connection = new SqliteConnection($"Filename={dbFilePath}")) + { + connection.Open(); + + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath); + return; + } + } + + using var dbContext = _dbProvider.CreateDbContext(); + + var authenticatedDevices = connection.Query("SELECT * FROM Tokens"); + + foreach (var row in authenticatedDevices) + { + var dateCreatedStr = row.GetString(9); + _ = DateTime.TryParse(dateCreatedStr, out var dateCreated); + var dateLastActivityStr = row.GetString(10); + _ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity); + + if (row.IsDBNull(6)) + { + dbContext.ApiKeys.Add(new ApiKey(row.GetString(3)) + { + AccessToken = row.GetString(1), + DateCreated = dateCreated, + DateLastActivity = dateLastActivity + }); + } + else + { + var userId = row.GetGuid(6); + var user = _userManager.GetUserById(userId); + if (user is null) + { + // User doesn't exist, don't bring over the device. + continue; + } + + dbContext.Devices.Add(new Device( + userId, + row.GetString(3), + row.GetString(4), + row.GetString(5), + row.GetString(2)) + { + AccessToken = row.GetString(1), + IsActive = row.GetBoolean(8), + DateCreated = dateCreated, + DateLastActivity = dateLastActivity + }); + } + } + + var deviceOptions = connection.Query("SELECT * FROM Devices"); + var deviceIds = new HashSet(); + foreach (var row in deviceOptions) + { + if (row.IsDBNull(2)) + { + continue; + } + + var deviceId = row.GetString(2); + if (deviceIds.Contains(deviceId)) + { + continue; + } + + deviceIds.Add(deviceId); + + dbContext.DeviceOptions.Add(new DeviceOptions(deviceId) + { + CustomName = row.IsDBNull(1) ? null : row.GetString(1) + }); + } + + dbContext.SaveChanges(); + } + + try + { + File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old")); + + var journalPath = Path.Combine(dataPath, DbFilename + "-journal"); + if (File.Exists(journalPath)) + { + File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal")); + } + } + catch (IOException e) + { + _logger.LogError(e, "Error renaming legacy activity log database to 'authentication.db.old'"); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs new file mode 100644 index 0000000000..56614ece3c --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs @@ -0,0 +1,70 @@ +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; + +/// +/// Properly set playlist owner. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2025-04-20T15:00:00", nameof(FixPlaylistOwner), "615DFA9E-2497-4DBB-A472-61938B752C5B")] +internal class FixPlaylistOwner : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete +{ + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IPlaylistManager _playlistManager; + + public FixPlaylistOwner( + ILogger logger, + ILibraryManager libraryManager, + IPlaylistManager playlistManager) + { + _logger = logger; + _libraryManager = libraryManager; + _playlistManager = playlistManager; + } + + /// + public void Perform() + { + var playlists = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Playlist } + }) + .Cast() + .Where(x => x.OwnerUserId.Equals(Guid.Empty)) + .ToArray(); + + if (playlists.Length > 0) + { + foreach (var playlist in playlists) + { + var shares = playlist.Shares; + if (shares.Count > 0) + { + var firstEditShare = shares.First(x => x.CanEdit); + if (firstEditShare is not null) + { + playlist.OwnerUserId = firstEditShare.UserId; + playlist.Shares = shares.Where(x => x != firstEditShare).ToArray(); + playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + _playlistManager.SavePlaylistFile(playlist); + } + } + else + { + playlist.OpenAccess = true; + playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs new file mode 100644 index 0000000000..00d152b4b8 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs @@ -0,0 +1,45 @@ +using System; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.System; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migration to add the default cast receivers to the system config. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2025-04-20T16:00:00", nameof(AddDefaultCastReceivers), "34A1A1C4-5572-418E-A2F8-32CDFE2668E8", RunMigrationOnSetup = true)] +public class AddDefaultCastReceivers : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete +{ + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public AddDefaultCastReceivers(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// + public void Perform() + { + _serverConfigurationManager.Configuration.CastReceiverApplications = + [ + new() + { + Id = "F007D354", + Name = "Stable" + }, + new() + { + Id = "6F511C87", + Name = "Unstable" + } + ]; + + _serverConfigurationManager.SaveConfiguration(); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs new file mode 100644 index 0000000000..f58cf27413 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs @@ -0,0 +1,46 @@ +using System; +using MediaBrowser.Controller.Configuration; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migration to update the default Jellyfin plugin repository. +/// +#pragma warning disable CS0618 // Type or member is obsolete +[JellyfinMigration("2025-04-20T17:00:00", nameof(UpdateDefaultPluginRepository), "852816E0-2712-49A9-9240-C6FC5FCAD1A8", RunMigrationOnSetup = true)] +public class UpdateDefaultPluginRepository : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete +{ + private const string NewRepositoryUrl = "https://repo.jellyfin.org/files/plugin/manifest.json"; + private const string OldRepositoryUrl = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"; + + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public UpdateDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// + public void Perform() + { + var updated = false; + foreach (var repo in _serverConfigurationManager.Configuration.PluginRepositories) + { + if (string.Equals(repo.Url, OldRepositoryUrl, StringComparison.OrdinalIgnoreCase)) + { + repo.Url = NewRepositoryUrl; + updated = true; + } + } + + if (updated) + { + _serverConfigurationManager.SaveConfiguration(); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs new file mode 100644 index 0000000000..d102e24b91 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs @@ -0,0 +1,76 @@ +using System.Linq; +using System.Threading; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// + /// Fixes the data column of audio types to be deserializable. + /// +#pragma warning disable CS0618 // Type or member is obsolete + [JellyfinMigration("2025-04-20T18:00:00", nameof(FixAudioData), "CF6FABC2-9FBE-4933-84A5-FFE52EF22A58")] + [JellyfinMigrationBackup(LegacyLibraryDb = true)] + internal class FixAudioData : IMigrationRoutine +#pragma warning restore CS0618 // Type or member is obsolete + { + private readonly ILogger _logger; + private readonly IItemRepository _itemRepository; + private readonly IItemCountService _countService; + private readonly IItemPersistenceService _persistenceService; + + public FixAudioData( + ILoggerFactory loggerFactory, + IItemRepository itemRepository, + IItemCountService countService, + IItemPersistenceService persistenceService) + { + _itemRepository = itemRepository; + _countService = countService; + _persistenceService = persistenceService; + _logger = loggerFactory.CreateLogger(); + } + + /// + public void Perform() + { + _logger.LogInformation("Backfilling audio lyrics data to database."); + var startIndex = 0; + var records = _countService.GetCount(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Audio], + }); + + while (startIndex < records) + { + var results = _itemRepository.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Audio], + StartIndex = startIndex, + Limit = 5000, + SkipDeserialization = true + }) + .Cast