diff options
172 files changed, 3219 insertions, 3111 deletions
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7153d4cf5..6a04d1326 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '7.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2 + uses: github/codeql-action/init@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2 + uses: github/codeql-action/autobuild@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # v2 + uses: github/codeql-action/analyze@a34ca99b4610d924e04c68db79e503e1f79f9f02 # v2 diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml index 897b7014a..7f6fcffed 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -19,6 +19,7 @@ jobs: days-before-pr-stale: -1 days-before-close: 21 days-before-pr-close: -1 + operations-per-run: 75 exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed stale-issue-label: stale stale-issue-message: |- diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 7ffb7118a..60e6dd644 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -28,7 +28,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 80bc57a5d..3106e2246 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -47,7 +47,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Emby.Notifications/Emby.Notifications.csproj b/Emby.Notifications/Emby.Notifications.csproj index 138965c89..eb269183e 100644 --- a/Emby.Notifications/Emby.Notifications.csproj +++ b/Emby.Notifications/Emby.Notifications.csproj @@ -23,7 +23,7 @@ <!-- Code analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index 34bc8f32f..ae6bc2db1 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -26,7 +26,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 7b3d07dfc..c18796611 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -193,11 +193,6 @@ namespace Emby.Server.Implementations /// </summary> private string PublishedServerUrl => _startupConfig[AddressOverrideKey]; - /// <summary> - /// Gets a value indicating whether this instance can self restart. - /// </summary> - public bool CanSelfRestart => _startupOptions.RestartPath is not null; - public bool CoreStartupHasCompleted { get; private set; } public virtual bool CanLaunchWebBrowser @@ -654,7 +649,7 @@ namespace Emby.Server.Implementations /// <returns>A task representing the service initialization operation.</returns> public async Task InitializeServices() { - var jellyfinDb = await Resolve<IDbContextFactory<JellyfinDb>>().CreateDbContextAsync().ConfigureAwait(false); + var jellyfinDb = await Resolve<IDbContextFactory<JellyfinDbContext>>().CreateDbContextAsync().ConfigureAwait(false); await using (jellyfinDb.ConfigureAwait(false)) { if ((await jellyfinDb.Database.GetPendingMigrationsAsync().ConfigureAwait(false)).Any()) @@ -935,17 +930,13 @@ namespace Emby.Server.Implementations /// </summary> public void Restart() { - if (!CanSelfRestart) - { - throw new PlatformNotSupportedException("The server is unable to self-restart. Please restart manually."); - } - if (IsShuttingDown) { return; } IsShuttingDown = true; + _pluginManager.UnloadAssemblies(); Task.Run(async () => { @@ -1047,7 +1038,6 @@ namespace Emby.Server.Implementations CachePath = ApplicationPaths.CachePath, OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name, - CanSelfRestart = CanSelfRestart, CanLaunchWebBrowser = CanLaunchWebBrowser, TranscodingTempPath = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index ff9aa4c2a..1d61667f8 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -61,10 +61,21 @@ namespace Emby.Server.Implementations.Data protected virtual int? CacheSize => null; /// <summary> + /// Gets the locking mode. <see href="https://www.sqlite.org/pragma.html#pragma_locking_mode" />. + /// </summary> + protected virtual string LockingMode => "EXCLUSIVE"; + + /// <summary> /// Gets the journal mode. <see href="https://www.sqlite.org/pragma.html#pragma_journal_mode" />. /// </summary> /// <value>The journal mode.</value> - protected virtual string JournalMode => "TRUNCATE"; + protected virtual string JournalMode => "WAL"; + + /// <summary> + /// Gets the journal size limit. <see href="https://www.sqlite.org/pragma.html#pragma_journal_size_limit" />. + /// </summary> + /// <value>The journal size limit.</value> + protected virtual int? JournalSizeLimit => 0; /// <summary> /// Gets the page size. @@ -84,7 +95,7 @@ namespace Emby.Server.Implementations.Data /// </summary> /// <value>The synchronous mode or null.</value> /// <see cref="SynchronousMode"/> - protected virtual SynchronousMode? Synchronous => null; + protected virtual SynchronousMode? Synchronous => SynchronousMode.Normal; /// <summary> /// Gets or sets the write lock. @@ -116,11 +127,21 @@ namespace Emby.Server.Implementations.Data WriteConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); } + if (!string.IsNullOrWhiteSpace(LockingMode)) + { + WriteConnection.Execute("PRAGMA locking_mode=" + LockingMode); + } + if (!string.IsNullOrWhiteSpace(JournalMode)) { WriteConnection.Execute("PRAGMA journal_mode=" + JournalMode); } + if (JournalSizeLimit.HasValue) + { + WriteConnection.Execute("PRAGMA journal_size_limit=" + (int)JournalSizeLimit.Value); + } + if (Synchronous.HasValue) { WriteConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 763ff77f1..bc703fe90 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -359,8 +359,6 @@ namespace Emby.Server.Implementations.Data string[] queries = { - "PRAGMA locking_mode=EXCLUSIVE", - "create table if not exists TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL)", "create table if not exists AncestorIds (ItemId GUID NOT NULL, AncestorId GUID NOT NULL, AncestorIdText TEXT NOT NULL, PRIMARY KEY (ItemId, AncestorId))", @@ -385,39 +383,6 @@ namespace Emby.Server.Implementations.Data string[] postQueries = { - // obsolete - "drop index if exists idx_TypedBaseItems", - "drop index if exists idx_mediastreams", - "drop index if exists idx_mediastreams1", - "drop index if exists idx_" + ChaptersTableName, - "drop index if exists idx_UserDataKeys1", - "drop index if exists idx_UserDataKeys2", - "drop index if exists idx_TypeTopParentId3", - "drop index if exists idx_TypeTopParentId2", - "drop index if exists idx_TypeTopParentId4", - "drop index if exists idx_Type", - "drop index if exists idx_TypeTopParentId", - "drop index if exists idx_GuidType", - "drop index if exists idx_TopParentId", - "drop index if exists idx_TypeTopParentId6", - "drop index if exists idx_ItemValues2", - "drop index if exists Idx_ProviderIds", - "drop index if exists idx_ItemValues3", - "drop index if exists idx_ItemValues4", - "drop index if exists idx_ItemValues5", - "drop index if exists idx_UserDataKeys3", - "drop table if exists UserDataKeys", - "drop table if exists ProviderIds", - "drop index if exists Idx_ProviderIds1", - "drop table if exists Images", - "drop index if exists idx_Images", - "drop index if exists idx_TypeSeriesPresentationUniqueKey", - "drop index if exists idx_SeriesPresentationUniqueKey", - "drop index if exists idx_TypeSeriesPresentationUniqueKey2", - "drop index if exists idx_AncestorIds3", - "drop index if exists idx_AncestorIds4", - "drop index if exists idx_AncestorIds2", - "create index if not exists idx_PathTypedBaseItems on TypedBaseItems(Path)", "create index if not exists idx_ParentIdTypedBaseItems on TypedBaseItems(ParentId)", @@ -458,6 +423,9 @@ namespace Emby.Server.Implementations.Data // Used to update inherited tags "create index if not exists idx_ItemValues8 on ItemValues(Type, ItemId, Value)", + + "CREATE INDEX IF NOT EXISTS idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type)", + "CREATE INDEX IF NOT EXISTS idx_PeopleNameListOrder ON People(Name, ListOrder)" }; using (var connection = GetConnection()) diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 7accc3b8b..7eaef094b 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -54,7 +54,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index 3769ae4dd..b7bcaace1 100644 --- a/Emby.Server.Implementations/IStartupOptions.cs +++ b/Emby.Server.Implementations/IStartupOptions.cs @@ -21,16 +21,6 @@ namespace Emby.Server.Implementations string? PackageName { get; } /// <summary> - /// Gets the value of the --restartpath command line option. - /// </summary> - string? RestartPath { get; } - - /// <summary> - /// Gets the value of the --restartargs command line option. - /// </summary> - string? RestartArgs { get; } - - /// <summary> /// Gets the value of the --published-server-url command line option. /// </summary> string? PublishedServerUrl { get; } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 4508363b0..93d50e6e3 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -3,9 +3,9 @@ "AppDeviceValues": "تطبيق: {0}, جهاز: {1}", "Application": "تطبيق", "Artists": "الفنانين", - "AuthenticationSucceededWithUserName": "تمت مصادقة {0} بنجاح", + "AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}", "Books": "الكتب", - "CameraImageUploadedFrom": "صورة كاميرا جديدة تم رفعها من {0}", + "CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}", "Channels": "القنوات", "ChapterNameValue": "الفصل {0}", "Collections": "التجميعات", @@ -16,7 +16,7 @@ "Folders": "المجلدات", "Genres": "التصنيفات", "HeaderAlbumArtists": "فناني الألبوم", - "HeaderContinueWatching": "استمر بالمشاهدة", + "HeaderContinueWatching": "استئناف المشاهدة", "HeaderFavoriteAlbums": "الألبومات المفضلة", "HeaderFavoriteArtists": "الفنانون المفضلون", "HeaderFavoriteEpisodes": "الحلقات المفضلة", @@ -27,15 +27,15 @@ "HeaderRecordingGroups": "مجموعات التسجيل", "HomeVideos": "الفيديوهات الشخصية", "Inherit": "توريث", - "ItemAddedWithName": "تم إضافة {0} للمكتبة", - "ItemRemovedWithName": "تم إزالة {0} من المكتبة", + "ItemAddedWithName": "أُضيف {0} للمكتبة", + "ItemRemovedWithName": "أُزيل {0} من المكتبة", "LabelIpAddressValue": "عنوان الآي بي: {0}", "LabelRunningTimeValue": "مدة التشغيل: {0}", "Latest": "أحدث", - "MessageApplicationUpdated": "لقد تم تحديث خادم Jellyfin", - "MessageApplicationUpdatedTo": "تم تحديث خادم Jellyfin الى {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "تم تحديث إعدادات الخادم في قسم {0}", - "MessageServerConfigurationUpdated": "تم تحديث إعدادات الخادم", + "MessageApplicationUpdated": "حُدث خادم Jellyfin", + "MessageApplicationUpdatedTo": "حُدث خادم Jellyfin إلى {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "حُدثت إعدادات الخادم في قسم {0}", + "MessageServerConfigurationUpdated": "حُدثت إعدادات الخادم", "MixedContent": "محتوى مختلط", "Movies": "الأفلام", "Music": "الموسيقى", @@ -45,14 +45,14 @@ "NameSeasonUnknown": "الموسم غير معروف", "NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.", "NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق", - "NotificationOptionApplicationUpdateInstalled": "تم تحديث التطبيق", + "NotificationOptionApplicationUpdateInstalled": "نُصب تحديث التطبيق", "NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي", - "NotificationOptionAudioPlaybackStopped": "تم إيقاف تشغيل المقطع الصوتي", - "NotificationOptionCameraImageUploaded": "تم رفع صورة الكاميرا", + "NotificationOptionAudioPlaybackStopped": "أُوقف تشغيل المقطع الصوتي", + "NotificationOptionCameraImageUploaded": "رُفعت صورة الكاميرا", "NotificationOptionInstallationFailed": "فشل في التثبيت", - "NotificationOptionNewLibraryContent": "تم إضافة محتوى جديد", + "NotificationOptionNewLibraryContent": "أُضيف محتوى جديدا", "NotificationOptionPluginError": "فشل في الملحق", - "NotificationOptionPluginInstalled": "تم تثبيت الملحق", + "NotificationOptionPluginInstalled": "ثُبتت المكونات الإضافية", "NotificationOptionPluginUninstalled": "تمت إزالة الملحق", "NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق", "NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم", diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 64cb36fd8..13b99cc99 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Оптимизирай базата данни", "TaskKeyframeExtractorDescription": "Извличат се ключови кадри от видеофайловете ,за да се създаде по точен ХЛС списък . Задачата може да отнеме много време.", "TaskKeyframeExtractor": "Извличане на ключови кадри", - "External": "Външен" + "External": "Външен", + "HearingImpaired": "Увреден слух" } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index 506c14fdc..466c8a990 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -13,5 +13,11 @@ "DeviceOfflineWithName": "{0} abandoned ship", "AppDeviceValues": "Captain: {0}, Ship: {1}", "CameraImageUploadedFrom": "Yer looking glass has glimpsed another painting from {0}", - "Collections": "Barrels" + "Collections": "Barrels", + "ItemAddedWithName": "{0} is now with yer treasure", + "Default": "Normal-like", + "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}", + "Favorites": "Finest Loot", + "ItemRemovedWithName": "{0} was taken from yer treasure", + "LabelIpAddressValue": "Ship's coordinates: {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index dc45a8264..65cf29e80 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -1,6 +1,6 @@ { "Albums": "Альбомы", - "AppDeviceValues": "Приложение.: {0}, Устройство.: {1}", + "AppDeviceValues": "Приложение: {0}, Устройство: {1}", "Application": "Приложение", "Artists": "Исполнители", "AuthenticationSucceededWithUserName": "{0} - авторизация успешна", @@ -50,7 +50,7 @@ "NotificationOptionAudioPlaybackStopped": "Воспроизведение аудио остановлено", "NotificationOptionCameraImageUploaded": "Изображения с камеры загружены", "NotificationOptionInstallationFailed": "Сбой установки", - "NotificationOptionNewLibraryContent": "Новое содержание добавлено", + "NotificationOptionNewLibraryContent": "Новое содержимое добавлено", "NotificationOptionPluginError": "Сбой плагина", "NotificationOptionPluginInstalled": "Плагин установлен", "NotificationOptionPluginUninstalled": "Плагин удалён", diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 1be8867f4..9739358df 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabaseDescription": "Сажима базу података и скраћује слободан простор. Покретање овог задатка након скенирања библиотеке или других промена које подразумевају измене базе података које могу побољшати перформансе.", "External": "Спољно", "TaskKeyframeExtractorDescription": "Екстрактује кљулне сличице из видео датотека да би креирао више преицзну HLS плеј-листу. Овај задатак може да потраје дуже време.", - "TaskKeyframeExtractor": "Екстрактор кључних сличица" + "TaskKeyframeExtractor": "Екстрактор кључних сличица", + "HearingImpaired": "ослабљен слух" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 9407a7b92..1a4fef64e 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -120,5 +120,6 @@ "Forced": "บังคับใช้", "TaskOptimizeDatabase": "ปรับปรุงประสิทธิภาพฐานข้อมูล", "TaskOptimizeDatabaseDescription": "ลดขนาดการจัดเก็บฐานข้อมูล ใช้งานคำสั่งนี้หลังจากสแกนไลบรารีหรือหลังจากการเปลี่ยนแปลงฐานข้อมูล อาจจะทำให้ระบบทำงานเร็วขึ้น", - "External": "ภายนอก" + "External": "ภายนอก", + "HearingImpaired": "บกพร่องทางการได้ยิน" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index baa9ecc1c..cdc25ec7c 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -9,15 +9,15 @@ "Channels": "頻道", "ChapterNameValue": "章節 {0}", "Collections": "合輯", - "DeviceOfflineWithName": "{0} 已經斷開連結", + "DeviceOfflineWithName": "{0} 已經斷開連接", "DeviceOnlineWithName": "{0} 已經連接", - "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗", + "FailedLoginAttemptWithUserName": "{0} 登入失敗", "Favorites": "我的最愛", "Folders": "資料夾", "Genres": "風格", "HeaderAlbumArtists": "專輯藝人", "HeaderContinueWatching": "繼續觀看", - "HeaderFavoriteAlbums": "最愛專輯", + "HeaderFavoriteAlbums": "最愛的專輯", "HeaderFavoriteArtists": "最愛的藝人", "HeaderFavoriteEpisodes": "最愛的劇集", "HeaderFavoriteShows": "最愛的節目", @@ -44,10 +44,10 @@ "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季數", "NewVersionIsAvailable": "新版本的 Jellyfin 伺服器可供下載。", - "NotificationOptionApplicationUpdateAvailable": "有可用的應用程式更新", + "NotificationOptionApplicationUpdateAvailable": "有可用的更新", "NotificationOptionApplicationUpdateInstalled": "應用程式已更新", - "NotificationOptionAudioPlayback": "開始播放音頻", - "NotificationOptionAudioPlaybackStopped": "已停止播放音頻", + "NotificationOptionAudioPlayback": "開始播放音訊", + "NotificationOptionAudioPlaybackStopped": "已停止播放音訊", "NotificationOptionCameraImageUploaded": "相片已上傳", "NotificationOptionInstallationFailed": "安裝失敗", "NotificationOptionNewLibraryContent": "已添加新内容", diff --git a/Emby.Server.Implementations/Plugins/PluginLoadContext.cs b/Emby.Server.Implementations/Plugins/PluginLoadContext.cs new file mode 100644 index 000000000..d04e9cf68 --- /dev/null +++ b/Emby.Server.Implementations/Plugins/PluginLoadContext.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Emby.Server.Implementations.Plugins; + +/// <summary> +/// A custom <see cref="AssemblyLoadContext"/> for loading Jellyfin plugins. +/// </summary> +public class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + /// <summary> + /// Initializes a new instance of the <see cref="PluginLoadContext"/> class. + /// </summary> + /// <param name="path">The path of the plugin assembly.</param> + public PluginLoadContext(string path) : base(true) + { + _resolver = new AssemblyDependencyResolver(path); + } + + /// <inheritdoc /> + protected override Assembly? Load(AssemblyName assemblyName) + { + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath is not null) + { + return LoadFromAssemblyPath(assemblyPath); + } + + return null; + } +} diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 14e7c2269..f2212f4dc 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; +using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -30,6 +31,7 @@ namespace Emby.Server.Implementations.Plugins { private readonly string _pluginsPath; private readonly Version _appVersion; + private readonly List<AssemblyLoadContext> _assemblyLoadContexts; private readonly JsonSerializerOptions _jsonOptions; private readonly ILogger<PluginManager> _logger; private readonly IApplicationHost _appHost; @@ -76,6 +78,8 @@ namespace Emby.Server.Implementations.Plugins _appHost = appHost; _minimumVersion = new Version(0, 0, 0, 1); _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>(); + + _assemblyLoadContexts = new List<AssemblyLoadContext>(); } private IHttpClientFactory HttpClientFactory @@ -124,7 +128,10 @@ namespace Emby.Server.Implementations.Plugins Assembly assembly; try { - assembly = Assembly.LoadFrom(file); + var assemblyLoadContext = new PluginLoadContext(file); + _assemblyLoadContexts.Add(assemblyLoadContext); + + assembly = assemblyLoadContext.LoadFromAssemblyPath(file); // Load all required types to verify that the plugin will load assembly.GetTypes(); @@ -156,6 +163,15 @@ namespace Emby.Server.Implementations.Plugins } } + /// <inheritdoc /> + public void UnloadAssemblies() + { + foreach (var assemblyLoadContext in _assemblyLoadContexts) + { + assemblyLoadContext.Unload(); + } + } + /// <summary> /// Creates all the plugin instances. /// </summary> diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 1efacd856..1f3cb9b63 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { private readonly ILogger<OptimizeDatabaseTask> _logger; private readonly ILocalizationManager _localization; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; /// <summary> /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks public OptimizeDatabaseTask( ILogger<OptimizeDatabaseTask> logger, ILocalizationManager localization, - IDbContextFactory<JellyfinDb> provider) + IDbContextFactory<JellyfinDbContext> provider) { _logger = logger; _localization = localization; diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 5f5f34b0d..ba9a57f1d 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -22,6 +22,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Authorization; @@ -1732,7 +1733,12 @@ namespace Jellyfin.Api.Controllers var channels = state.OutputAudioChannels; - if (channels.HasValue) + if (channels.HasValue + && (channels.Value != 2 + || (state.AudioStream is not null + && state.AudioStream.Channels.HasValue + && state.AudioStream.Channels.Value > 5 + && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) { args += " -ac " + channels.Value; } diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 534667c8c..f866655c0 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -28,7 +28,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Jellyfin.Api.Controllers @@ -2038,13 +2037,8 @@ namespace Jellyfin.Api.Controllers } var acceptParam = Request.Query[HeaderNames.Accept]; - if (StringValues.IsNullOrEmpty(acceptParam)) - { - return Array.Empty<ImageFormat>(); - } - // Can't be null, checked above - var supportsWebP = SupportsFormat(supportedFormats, acceptParam!, ImageFormat.Webp, false); + var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false); if (!supportsWebP) { @@ -2066,8 +2060,7 @@ namespace Jellyfin.Api.Controllers formats.Add(ImageFormat.Jpg); formats.Add(ImageFormat.Png); - // Can't be null, checked above - if (SupportsFormat(supportedFormats, acceptParam!, ImageFormat.Gif, true)) + if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true)) { formats.Add(ImageFormat.Gif); } @@ -2075,7 +2068,7 @@ namespace Jellyfin.Api.Controllers return formats.ToArray(); } - private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string acceptParam, ImageFormat format, bool acceptAll) + private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll) { if (requestAcceptTypes.Contains(format.GetMimeType())) { diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs index ea8c5ecdb..8f1f5dd94 100644 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs @@ -2,7 +2,7 @@ using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Server.Formatters +namespace Jellyfin.Api.Formatters { /// <summary> /// Camel Case Json Profile Formatter. diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Api/Formatters/CssOutputFormatter.cs index fdaa48f84..e88c8ad1b 100644 --- a/Jellyfin.Server/Formatters/CssOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/CssOutputFormatter.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; -namespace Jellyfin.Server.Formatters +namespace Jellyfin.Api.Formatters { /// <summary> /// Css output formatter. diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs index 03ca7dda7..5d77dbf4c 100644 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs @@ -3,7 +3,7 @@ using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Server.Formatters +namespace Jellyfin.Api.Formatters { /// <summary> /// Pascal Case Json Profile Formatter. diff --git a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs index 156368d69..df8b1650b 100644 --- a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; -namespace Jellyfin.Server.Formatters +namespace Jellyfin.Api.Formatters { /// <summary> /// Xml output formatter. diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index b5444138f..45725ec3e 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -27,7 +27,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs index 6ee5bf38a..6bd9e0b08 100644 --- a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs +++ b/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Redirect requests without baseurl prefix to the baseurl prefixed URL. diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs index 91dbce19a..6b3aeb187 100644 --- a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs @@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Exception Middleware. diff --git a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs index 0afcd61a0..f7af91e48 100644 --- a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -4,7 +4,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Validates the IP of requests coming from local networks wrt. remote access. diff --git a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs index 67bf24d2a..18f13bbce 100644 --- a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs +++ b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs @@ -5,7 +5,7 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Validates the LAN host IP based on application configuration. diff --git a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs index b214299df..b73923c1e 100644 --- a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs +++ b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Removes /emby and /mediabrowser from requested route. diff --git a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs index 24807ce38..4b6304e0e 100644 --- a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs +++ b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// URL decodes the querystring before binding. diff --git a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs index 531897cd4..3701d0f45 100644 --- a/Jellyfin.Server/Middleware/ResponseTimeMiddleware.cs +++ b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Logging; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Response time middleware. diff --git a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs index fabcd2da7..2e69580be 100644 --- a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs +++ b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Redirect requests to robots.txt to web/robots.txt. diff --git a/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs index 2ec063392..dcd64401a 100644 --- a/Jellyfin.Server/Middleware/ServerStartupMessageMiddleware.cs +++ b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs @@ -5,7 +5,7 @@ using MediaBrowser.Controller; using MediaBrowser.Model.Globalization; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Shows a custom message during server startup. diff --git a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs index 2f1d79157..d35e0fcfd 100644 --- a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs +++ b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Defines the <see cref="UrlDecodeQueryFeature"/>. diff --git a/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs index b7a5d2b34..2cf1e5e4a 100644 --- a/Jellyfin.Server/Middleware/WebSocketHandlerMiddleware.cs +++ b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Server.Middleware +namespace Jellyfin.Api.Middleware { /// <summary> /// Handles WebSocket requests. diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 7fe6466d4..540534e1b 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -29,7 +29,7 @@ <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs b/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs deleted file mode 100644 index 6136a2ff9..000000000 --- a/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using MediaBrowser.Model.Drawing; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Static helper class used to draw percentage-played indicators on images. - /// </summary> - public static class PercentPlayedDrawer - { - private const int IndicatorHeight = 8; - - /// <summary> - /// Draw a percentage played indicator on a canvas. - /// </summary> - /// <param name="canvas">The canvas to draw the indicator on.</param> - /// <param name="imageSize">The size of the image being drawn on.</param> - /// <param name="percent">The percentage played to display with the indicator.</param> - public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent) - { - using var paint = new SKPaint(); - var endX = imageSize.Width - 1; - var endY = imageSize.Height - 1; - - paint.Color = SKColor.Parse("#99000000"); - paint.Style = SKPaintStyle.Fill; - canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint); - - double foregroundWidth = (endX * percent) / 100; - - paint.Color = SKColor.Parse("#FF00A4DC"); - canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint); - } - } -} diff --git a/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs b/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs deleted file mode 100644 index 2a3729942..000000000 --- a/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs +++ /dev/null @@ -1,48 +0,0 @@ -using MediaBrowser.Model.Drawing; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Static helper class for drawing 'played' indicators. - /// </summary> - public static class PlayedIndicatorDrawer - { - private const int OffsetFromTopRightCorner = 38; - - /// <summary> - /// Draw a 'played' indicator in the top right corner of a canvas. - /// </summary> - /// <param name="canvas">The canvas to draw the indicator on.</param> - /// <param name="imageSize"> - /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the - /// indicator. - /// </param> - public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize) - { - var x = imageSize.Width - OffsetFromTopRightCorner; - - using var paint = new SKPaint - { - Color = SKColor.Parse("#CC00A4DC"), - Style = SKPaintStyle.Fill - }; - - canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); - - paint.Color = new SKColor(255, 255, 255, 255); - paint.TextSize = 30; - paint.IsAntialias = true; - - // or: - // var emojiChar = 0x1F680; - const string Text = "✔️"; - var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32); - - // ask the font manager for a font with that character - paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar); - - canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint); - } - } -} diff --git a/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/Jellyfin.Drawing.Skia/SkiaCodecException.cs deleted file mode 100644 index 9a50a4d62..000000000 --- a/Jellyfin.Drawing.Skia/SkiaCodecException.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Globalization; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Represents errors that occur during interaction with Skia codecs. - /// </summary> - public class SkiaCodecException : SkiaException - { - /// <summary> - /// Initializes a new instance of the <see cref="SkiaCodecException" /> class. - /// </summary> - /// <param name="result">The non-successful codec result returned by Skia.</param> - public SkiaCodecException(SKCodecResult result) - { - CodecResult = result; - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaCodecException" /> class - /// with a specified error message. - /// </summary> - /// <param name="result">The non-successful codec result returned by Skia.</param> - /// <param name="message">The message that describes the error.</param> - public SkiaCodecException(SKCodecResult result, string message) - : base(message) - { - CodecResult = result; - } - - /// <summary> - /// Gets the non-successful codec result returned by Skia. - /// </summary> - public SKCodecResult CodecResult { get; } - - /// <inheritdoc /> - public override string ToString() - => string.Format( - CultureInfo.InvariantCulture, - "Non-success codec result: {0}\n{1}", - CodecResult, - base.ToString()); - } -} diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs deleted file mode 100644 index 9171c4d6e..000000000 --- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ /dev/null @@ -1,545 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using BlurHashSharp.SkiaSharp; -using Jellyfin.Extensions; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Model.Drawing; -using Microsoft.Extensions.Logging; -using SkiaSharp; -using SKSvg = SkiaSharp.Extended.Svg.SKSvg; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images. - /// </summary> - public class SkiaEncoder : IImageEncoder - { - private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" }; - - private readonly ILogger<SkiaEncoder> _logger; - private readonly IApplicationPaths _appPaths; - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaEncoder"/> class. - /// </summary> - /// <param name="logger">The application logger.</param> - /// <param name="appPaths">The application paths.</param> - public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths) - { - _logger = logger; - _appPaths = appPaths; - } - - /// <inheritdoc/> - public string Name => "Skia"; - - /// <inheritdoc/> - public bool SupportsImageCollageCreation => true; - - /// <inheritdoc/> - public bool SupportsImageEncoding => true; - - /// <inheritdoc/> - public IReadOnlyCollection<string> SupportedInputFormats => - new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "jpeg", - "jpg", - "png", - "dng", - "webp", - "gif", - "bmp", - "ico", - "astc", - "ktx", - "pkm", - "wbmp", - // TODO: check if these are supported on multiple platforms - // https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454 - // working on windows at least - "cr2", - "nef", - "arw" - }; - - /// <inheritdoc/> - public IReadOnlyCollection<ImageFormat> SupportedOutputFormats - => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png }; - - /// <summary> - /// Check if the native lib is available. - /// </summary> - /// <returns>True if the native lib is available, otherwise false.</returns> - public static bool IsNativeLibAvailable() - { - try - { - // test an operation that requires the native library - SKPMColor.PreMultiply(SKColors.Black); - return true; - } - catch (Exception) - { - return false; - } - } - - /// <summary> - /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>. - /// </summary> - /// <param name="selectedFormat">The format to convert.</param> - /// <returns>The converted format.</returns> - public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat) - { - return selectedFormat switch - { - ImageFormat.Bmp => SKEncodedImageFormat.Bmp, - ImageFormat.Jpg => SKEncodedImageFormat.Jpeg, - ImageFormat.Gif => SKEncodedImageFormat.Gif, - ImageFormat.Webp => SKEncodedImageFormat.Webp, - _ => SKEncodedImageFormat.Png - }; - } - - /// <inheritdoc /> - /// <exception cref="FileNotFoundException">The path is not valid.</exception> - public ImageDimensions GetImageSize(string path) - { - if (!File.Exists(path)) - { - throw new FileNotFoundException("File not found", path); - } - - var extension = Path.GetExtension(path.AsSpan()); - if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase)) - { - var svg = new SKSvg(); - svg.Load(path); - return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height)); - } - - using var codec = SKCodec.Create(path, out SKCodecResult result); - switch (result) - { - case SKCodecResult.Success: - var info = codec.Info; - return new ImageDimensions(info.Width, info.Height); - case SKCodecResult.Unimplemented: - _logger.LogDebug("Image format not supported: {FilePath}", path); - return new ImageDimensions(0, 0); - default: - _logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result); - return new ImageDimensions(0, 0); - } - } - - /// <inheritdoc /> - /// <exception cref="ArgumentNullException">The path is null.</exception> - /// <exception cref="FileNotFoundException">The path is not valid.</exception> - /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception> - public string GetImageBlurHash(int xComp, int yComp, string path) - { - ArgumentException.ThrowIfNullOrEmpty(path); - - var extension = Path.GetExtension(path.AsSpan()).TrimStart('.'); - if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path); - return string.Empty; - } - - // Any larger than 128x128 is too slow and there's no visually discernible difference - return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); - } - - private bool RequiresSpecialCharacterHack(string path) - { - for (int i = 0; i < path.Length; i++) - { - if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter) - { - return true; - } - } - - return path.HasDiacritics(); - } - - private string NormalizePath(string path) - { - if (!RequiresSpecialCharacterHack(path)) - { - return path; - } - - var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path)); - var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); - Directory.CreateDirectory(directory); - File.Copy(path, tempPath, true); - - return tempPath; - } - - private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation) - { - if (!orientation.HasValue) - { - return SKEncodedOrigin.TopLeft; - } - - return orientation.Value switch - { - ImageOrientation.TopRight => SKEncodedOrigin.TopRight, - ImageOrientation.RightTop => SKEncodedOrigin.RightTop, - ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom, - ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop, - ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom, - ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight, - ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft, - _ => SKEncodedOrigin.TopLeft - }; - } - - /// <summary> - /// Decode an image. - /// </summary> - /// <param name="path">The filepath of the image to decode.</param> - /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param> - /// <param name="orientation">The orientation of the image.</param> - /// <param name="origin">The detected origin of the image.</param> - /// <returns>The resulting bitmap of the image.</returns> - internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin) - { - if (!File.Exists(path)) - { - throw new FileNotFoundException("File not found", path); - } - - var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path)); - - if (requiresTransparencyHack || forceCleanBitmap) - { - using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res); - if (res != SKCodecResult.Success) - { - origin = GetSKEncodedOrigin(orientation); - return null; - } - - // create the bitmap - var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); - - // decode - _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels()); - - origin = codec.EncodedOrigin; - - return bitmap; - } - - var resultBitmap = SKBitmap.Decode(NormalizePath(path)); - - if (resultBitmap is null) - { - return Decode(path, true, orientation, out origin); - } - - // If we have to resize these they often end up distorted - if (resultBitmap.ColorType == SKColorType.Gray8) - { - using (resultBitmap) - { - return Decode(path, true, orientation, out origin); - } - } - - origin = SKEncodedOrigin.TopLeft; - return resultBitmap; - } - - private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation) - { - if (autoOrient) - { - var bitmap = Decode(path, true, orientation, out var origin); - - if (bitmap is not null && origin != SKEncodedOrigin.TopLeft) - { - using (bitmap) - { - return OrientImage(bitmap, origin); - } - } - - return bitmap; - } - - return Decode(path, false, orientation, out _); - } - - private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) - { - var needsFlip = origin == SKEncodedOrigin.LeftBottom - || origin == SKEncodedOrigin.LeftTop - || origin == SKEncodedOrigin.RightBottom - || origin == SKEncodedOrigin.RightTop; - var rotated = needsFlip - ? new SKBitmap(bitmap.Height, bitmap.Width) - : new SKBitmap(bitmap.Width, bitmap.Height); - using var surface = new SKCanvas(rotated); - var midX = (float)rotated.Width / 2; - var midY = (float)rotated.Height / 2; - - switch (origin) - { - case SKEncodedOrigin.TopRight: - surface.Scale(-1, 1, midX, midY); - break; - case SKEncodedOrigin.BottomRight: - surface.RotateDegrees(180, midX, midY); - break; - case SKEncodedOrigin.BottomLeft: - surface.Scale(1, -1, midX, midY); - break; - case SKEncodedOrigin.LeftTop: - surface.Translate(0, -rotated.Height); - surface.Scale(1, -1, midX, midY); - surface.RotateDegrees(-90); - break; - case SKEncodedOrigin.RightTop: - surface.Translate(rotated.Width, 0); - surface.RotateDegrees(90); - break; - case SKEncodedOrigin.RightBottom: - surface.Translate(rotated.Width, 0); - surface.Scale(1, -1, midX, midY); - surface.RotateDegrees(90); - break; - case SKEncodedOrigin.LeftBottom: - surface.Translate(0, rotated.Height); - surface.RotateDegrees(-90); - break; - } - - surface.DrawBitmap(bitmap, 0, 0); - return rotated; - } - - /// <summary> - /// Resizes an image on the CPU, by utilizing a surface and canvas. - /// - /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect. - /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html). - /// </summary> - /// <param name="source">The source bitmap.</param> - /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param> - /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param> - /// <param name="isDither">This enables dithering on the SKPaint instance.</param> - /// <returns>The resized image.</returns> - internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false) - { - using var surface = SKSurface.Create(targetInfo); - using var canvas = surface.Canvas; - using var paint = new SKPaint - { - FilterQuality = SKFilterQuality.High, - IsAntialias = isAntialias, - IsDither = isDither - }; - - var kernel = new float[9] - { - 0, -.1f, 0, - -.1f, 1.4f, -.1f, - 0, -.1f, 0, - }; - - var kernelSize = new SKSizeI(3, 3); - var kernelOffset = new SKPointI(1, 1); - - paint.ImageFilter = SKImageFilter.CreateMatrixConvolution( - kernelSize, - kernel, - 1f, - 0f, - kernelOffset, - SKShaderTileMode.Clamp, - true); - - canvas.DrawBitmap( - source, - SKRect.Create(0, 0, source.Width, source.Height), - SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height), - paint); - - return surface.Snapshot(); - } - - /// <inheritdoc/> - public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) - { - ArgumentException.ThrowIfNullOrEmpty(inputPath); - ArgumentException.ThrowIfNullOrEmpty(outputPath); - - var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.'); - if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath); - return inputPath; - } - - var skiaOutputFormat = GetImageFormat(outputFormat); - - var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); - var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); - var blur = options.Blur ?? 0; - var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); - - using var bitmap = GetBitmap(inputPath, autoOrient, orientation); - if (bitmap is null) - { - throw new InvalidDataException($"Skia unable to read image {inputPath}"); - } - - var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); - - if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient) - { - // Just spit out the original file if all the options are default - return inputPath; - } - - var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize); - - var width = newImageSize.Width; - var height = newImageSize.Height; - - // scale image (the FromImage creates a copy) - var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace); - using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo)); - - // If all we're doing is resizing then we can stop now - if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) - { - var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - Directory.CreateDirectory(outputDirectory); - using var outputStream = new SKFileWStream(outputPath); - using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels()); - resizedBitmap.Encode(outputStream, skiaOutputFormat, quality); - return outputPath; - } - - // create bitmap to use for canvas drawing used to draw into bitmap - using var saveBitmap = new SKBitmap(width, height); - using var canvas = new SKCanvas(saveBitmap); - // set background color if present - if (hasBackgroundColor) - { - canvas.Clear(SKColor.Parse(options.BackgroundColor)); - } - - // Add blur if option is present - if (blur > 0) - { - // create image from resized bitmap to apply blur - using var paint = new SKPaint(); - using var filter = SKImageFilter.CreateBlur(blur, blur); - paint.ImageFilter = filter; - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); - } - else - { - // draw resized bitmap onto canvas - canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height)); - } - - // If foreground layer present then draw - if (hasForegroundColor) - { - if (!double.TryParse(options.ForegroundLayer, out double opacity)) - { - opacity = .4; - } - - canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver); - } - - if (hasIndicator) - { - DrawIndicator(canvas, width, height, options); - } - - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - Directory.CreateDirectory(directory); - using (var outputStream = new SKFileWStream(outputPath)) - { - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) - { - pixmap.Encode(outputStream, skiaOutputFormat, quality); - } - } - - return outputPath; - } - - /// <inheritdoc/> - public void CreateImageCollage(ImageCollageOptions options, string? libraryName) - { - double ratio = (double)options.Width / options.Height; - - if (ratio >= 1.4) - { - new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName); - } - else if (ratio >= .9) - { - new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); - } - else - { - // TODO: Create Poster collage capability - new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); - } - } - - /// <inheritdoc /> - public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) - { - var splashBuilder = new SplashscreenBuilder(this); - var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png"); - splashBuilder.GenerateSplash(posters, backdrops, outputPath); - } - - private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options) - { - try - { - var currentImageSize = new ImageDimensions(imageWidth, imageHeight); - - if (options.AddPlayedIndicator) - { - PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize); - } - else if (options.UnplayedCount.HasValue) - { - UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value); - } - - if (options.PercentPlayed > 0) - { - PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error drawing indicator overlay"); - } - } - } -} diff --git a/Jellyfin.Drawing.Skia/SkiaException.cs b/Jellyfin.Drawing.Skia/SkiaException.cs deleted file mode 100644 index 5b272eac5..000000000 --- a/Jellyfin.Drawing.Skia/SkiaException.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Represents errors that occur during interaction with Skia. - /// </summary> - public class SkiaException : Exception - { - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class. - /// </summary> - public SkiaException() - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message. - /// </summary> - /// <param name="message">The message that describes the error.</param> - public SkiaException(string message) : base(message) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a - /// reference to the inner exception that is the cause of this exception. - /// </summary> - /// <param name="message">The error message that explains the reason for the exception.</param> - /// <param name="innerException"> - /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if - /// no inner exception is specified. - /// </param> - public SkiaException(string message, Exception innerException) - : base(message, innerException) - { - } - } -} diff --git a/Jellyfin.Drawing.Skia/SkiaHelper.cs b/Jellyfin.Drawing.Skia/SkiaHelper.cs deleted file mode 100644 index 23e92dcb2..000000000 --- a/Jellyfin.Drawing.Skia/SkiaHelper.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Generic; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Class containing helper methods for working with SkiaSharp. - /// </summary> - public static class SkiaHelper - { - /// <summary> - /// Gets the next valid image as a bitmap. - /// </summary> - /// <param name="skiaEncoder">The current skia encoder.</param> - /// <param name="paths">The list of image paths.</param> - /// <param name="currentIndex">The current checked index.</param> - /// <param name="newIndex">The new index.</param> - /// <returns>A valid bitmap, or null if no bitmap exists after <c>currentIndex</c>.</returns> - public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex) - { - var imagesTested = new Dictionary<int, int>(); - SKBitmap? bitmap = null; - - while (imagesTested.Count < paths.Count) - { - if (currentIndex >= paths.Count) - { - currentIndex = 0; - } - - bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _); - - imagesTested[currentIndex] = 0; - - currentIndex++; - - if (bitmap is not null) - { - break; - } - } - - newIndex = currentIndex; - return bitmap; - } - } -} diff --git a/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs deleted file mode 100644 index 7fbae3349..000000000 --- a/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Collections.Generic; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Used to build the splashscreen. - /// </summary> - public class SplashscreenBuilder - { - private const int FinalWidth = 1920; - private const int FinalHeight = 1080; - // generated collage resolution should be higher than the final resolution - private const int WallWidth = FinalWidth * 3; - private const int WallHeight = FinalHeight * 2; - private const int Rows = 6; - private const int Spacing = 20; - - private readonly SkiaEncoder _skiaEncoder; - - /// <summary> - /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class. - /// </summary> - /// <param name="skiaEncoder">The SkiaEncoder.</param> - public SplashscreenBuilder(SkiaEncoder skiaEncoder) - { - _skiaEncoder = skiaEncoder; - } - - /// <summary> - /// Generate a splashscreen. - /// </summary> - /// <param name="posters">The poster paths.</param> - /// <param name="backdrops">The landscape paths.</param> - /// <param name="outputPath">The output path.</param> - public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath) - { - using var wall = GenerateCollage(posters, backdrops); - using var transformed = Transform3D(wall); - - using var outputStream = new SKFileWStream(outputPath); - using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels()); - pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90); - } - - /// <summary> - /// Generates a collage of posters and landscape pictures. - /// </summary> - /// <param name="posters">The poster paths.</param> - /// <param name="backdrops">The landscape paths.</param> - /// <returns>The created collage as a bitmap.</returns> - private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) - { - var posterIndex = 0; - var backdropIndex = 0; - - var bitmap = new SKBitmap(WallWidth, WallHeight); - using var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.Black); - - int posterHeight = WallHeight / 6; - - for (int i = 0; i < Rows; i++) - { - int imageCounter = Random.Shared.Next(0, 5); - int currentWidthPos = i * 75; - int currentHeight = i * (posterHeight + Spacing); - - while (currentWidthPos < WallWidth) - { - SKBitmap? currentImage; - - switch (imageCounter) - { - case 0: - case 2: - case 3: - currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex); - posterIndex = newPosterIndex; - break; - default: - currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex); - backdropIndex = newBackdropIndex; - break; - } - - if (currentImage is null) - { - throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!"); - } - - // resize to the same aspect as the original - var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height); - using var resizedBitmap = new SKBitmap(imageWidth, posterHeight); - currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High); - - // draw on canvas - canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight); - - currentWidthPos += imageWidth + Spacing; - - currentImage.Dispose(); - - if (imageCounter >= 4) - { - imageCounter = 0; - } - else - { - imageCounter++; - } - } - } - - return bitmap; - } - - /// <summary> - /// Transform the collage in 3D space. - /// </summary> - /// <param name="input">The bitmap to transform.</param> - /// <returns>The transformed image.</returns> - private SKBitmap Transform3D(SKBitmap input) - { - var bitmap = new SKBitmap(FinalWidth, FinalHeight); - using var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.Black); - var matrix = new SKMatrix - { - ScaleX = 0.324108899f, - ScaleY = 0.563934922f, - SkewX = -0.244337708f, - SkewY = 0.0377609022f, - TransX = 42.0407715f, - TransY = -198.104706f, - Persp0 = -9.08959337E-05f, - Persp1 = 6.85242048E-05f, - Persp2 = 0.988209724f - }; - - canvas.SetMatrix(matrix); - canvas.DrawBitmap(input, 0, 0); - - return bitmap; - } - } -} diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs deleted file mode 100644 index c8b8f3ace..000000000 --- a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.RegularExpressions; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Used to build collages of multiple images arranged in vertical strips. - /// </summary> - public class StripCollageBuilder - { - private readonly SkiaEncoder _skiaEncoder; - - /// <summary> - /// Initializes a new instance of the <see cref="StripCollageBuilder"/> class. - /// </summary> - /// <param name="skiaEncoder">The encoder to use for building collages.</param> - public StripCollageBuilder(SkiaEncoder skiaEncoder) - { - _skiaEncoder = skiaEncoder; - } - - /// <summary> - /// Check which format an image has been encoded with using its filename extension. - /// </summary> - /// <param name="outputPath">The path to the image to get the format for.</param> - /// <returns>The image format.</returns> - public static SKEncodedImageFormat GetEncodedFormat(string outputPath) - { - ArgumentNullException.ThrowIfNull(outputPath); - - var ext = Path.GetExtension(outputPath); - - if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase) - || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase)) - { - return SKEncodedImageFormat.Jpeg; - } - - if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase)) - { - return SKEncodedImageFormat.Webp; - } - - if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase)) - { - return SKEncodedImageFormat.Gif; - } - - if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase)) - { - return SKEncodedImageFormat.Bmp; - } - - // default to png - return SKEncodedImageFormat.Png; - } - - /// <summary> - /// Create a square collage. - /// </summary> - /// <param name="paths">The paths of the images to use in the collage.</param> - /// <param name="outputPath">The path at which to place the resulting collage image.</param> - /// <param name="width">The desired width of the collage.</param> - /// <param name="height">The desired height of the collage.</param> - public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height) - { - using var bitmap = BuildSquareCollageBitmap(paths, width, height); - using var outputStream = new SKFileWStream(outputPath); - using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()); - pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); - } - - /// <summary> - /// Create a thumb collage. - /// </summary> - /// <param name="paths">The paths of the images to use in the collage.</param> - /// <param name="outputPath">The path at which to place the resulting image.</param> - /// <param name="width">The desired width of the collage.</param> - /// <param name="height">The desired height of the collage.</param> - /// <param name="libraryName">The name of the library to draw on the collage.</param> - public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName) - { - using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName); - using var outputStream = new SKFileWStream(outputPath); - using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()); - pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); - } - - private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName) - { - var bitmap = new SKBitmap(width, height); - - using var canvas = new SKCanvas(bitmap); - canvas.Clear(SKColors.Black); - - using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _); - if (backdrop is null) - { - return bitmap; - } - - // resize to the same aspect as the original - var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width); - using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace)); - // draw the backdrop - canvas.DrawImage(residedBackdrop, 0, 0); - - // draw shadow rectangle - using var paintColor = new SKPaint - { - Color = SKColors.Black.WithAlpha(0x78), - Style = SKPaintStyle.Fill - }; - canvas.DrawRect(0, 0, width, height, paintColor); - - var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); - - // use the system fallback to find a typeface for the given CJK character - var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]"; - var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty); - if (!string.IsNullOrEmpty(filteredName)) - { - typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]); - } - - // draw library name - using var textPaint = new SKPaint - { - Color = SKColors.White, - Style = SKPaintStyle.Fill, - TextSize = 112, - TextAlign = SKTextAlign.Center, - Typeface = typeFace, - IsAntialias = true - }; - - // scale down text to 90% of the width if text is larger than 95% of the width - var textWidth = textPaint.MeasureText(libraryName); - if (textWidth > width * 0.95) - { - textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth; - } - - canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); - - return bitmap; - } - - private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height) - { - var bitmap = new SKBitmap(width, height); - var imageIndex = 0; - var cellWidth = width / 2; - var cellHeight = height / 2; - - using var canvas = new SKCanvas(bitmap); - for (var x = 0; x < 2; x++) - { - for (var y = 0; y < 2; y++) - { - using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex); - imageIndex = newIndex; - - if (currentBitmap is null) - { - continue; - } - - // Scale image. The FromBitmap creates a copy - var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace); - using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo)); - - // draw this image into the strip at the next position - var xPos = x * cellWidth; - var yPos = y * cellHeight; - canvas.DrawBitmap(resizedBitmap, xPos, yPos); - } - } - - return bitmap; - } - } -} diff --git a/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs deleted file mode 100644 index 58f887c96..000000000 --- a/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Globalization; -using MediaBrowser.Model.Drawing; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia -{ - /// <summary> - /// Static helper class for drawing unplayed count indicators. - /// </summary> - public static class UnplayedCountIndicator - { - /// <summary> - /// The x-offset used when drawing an unplayed count indicator. - /// </summary> - private const int OffsetFromTopRightCorner = 38; - - /// <summary> - /// Draw an unplayed count indicator in the top right corner of a canvas. - /// </summary> - /// <param name="canvas">The canvas to draw the indicator on.</param> - /// <param name="imageSize"> - /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the - /// indicator. - /// </param> - /// <param name="count">The number to draw in the indicator.</param> - public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count) - { - var x = imageSize.Width - OffsetFromTopRightCorner; - var text = count.ToString(CultureInfo.InvariantCulture); - - using var paint = new SKPaint - { - Color = SKColor.Parse("#CC00A4DC"), - Style = SKPaintStyle.Fill - }; - - canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); - - paint.Color = new SKColor(255, 255, 255, 255); - paint.TextSize = 24; - paint.IsAntialias = true; - - var y = OffsetFromTopRightCorner + 9; - - if (text.Length == 1) - { - x -= 7; - } - - if (text.Length == 2) - { - x -= 13; - } - else if (text.Length >= 3) - { - x -= 15; - y -= 2; - paint.TextSize = 18; - } - - canvas.DrawText(text, x, y, paint); - } - } -} diff --git a/Jellyfin.Networking/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj index 975d1c8ce..2c153d88b 100644 --- a/Jellyfin.Networking/Jellyfin.Networking.csproj +++ b/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -11,7 +11,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 9d6ca6aab..ce1c54cbb 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -15,13 +15,13 @@ namespace Jellyfin.Server.Implementations.Activity /// </summary> public class ActivityManager : IActivityManager { - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; /// <summary> /// Initializes a new instance of the <see cref="ActivityManager"/> class. /// </summary> /// <param name="provider">The Jellyfin database provider.</param> - public ActivityManager(IDbContextFactory<JellyfinDb> provider) + public ActivityManager(IDbContextFactory<JellyfinDbContext> provider) { _provider = provider; } @@ -48,18 +48,10 @@ namespace Jellyfin.Server.Implementations.Activity var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - IQueryable<ActivityLog> entries = dbContext.ActivityLogs - .OrderByDescending(entry => entry.DateCreated); - - if (query.MinDate.HasValue) - { - entries = entries.Where(entry => entry.DateCreated >= query.MinDate); - } - - if (query.HasUserId.HasValue) - { - entries = entries.Where(entry => (!entry.UserId.Equals(default)) == query.HasUserId.Value); - } + var entries = dbContext.ActivityLogs + .OrderByDescending(entry => entry.DateCreated) + .Where(entry => query.MinDate == null || entry.DateCreated >= query.MinDate) + .Where(entry => !query.HasUserId.HasValue || entry.UserId.Equals(default) != query.HasUserId.Value); return new QueryResult<ActivityLogEntry>( query.Skip, @@ -67,8 +59,16 @@ namespace Jellyfin.Server.Implementations.Activity await entries .Skip(query.Skip ?? 0) .Take(query.Limit ?? 100) - .AsAsyncEnumerable() - .Select(ConvertToOldModel) + .Select(entity => new ActivityLogEntry(entity.Name, entity.Type, entity.UserId) + { + Id = entity.Id, + Overview = entity.Overview, + ShortOverview = entity.ShortOverview, + ItemId = entity.ItemId, + Date = entity.DateCreated, + Severity = entity.LogSeverity + }) + .AsQueryable() .ToListAsync() .ConfigureAwait(false)); } diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 15ac5c668..8b15d6823 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -23,7 +23,7 @@ namespace Jellyfin.Server.Implementations.Devices /// </summary> public class DeviceManager : IDeviceManager { - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IUserManager _userManager; private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new(); @@ -32,7 +32,7 @@ namespace Jellyfin.Server.Implementations.Devices /// </summary> /// <param name="dbProvider">The database provider.</param> /// <param name="userManager">The user manager.</param> - public DeviceManager(IDbContextFactory<JellyfinDb> dbProvider, IUserManager userManager) + public DeviceManager(IDbContextFactory<JellyfinDbContext> dbProvider, IUserManager userManager) { _dbProvider = dbProvider; _userManager = userManager; @@ -54,7 +54,7 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - deviceOptions = await dbContext.DeviceOptions.AsQueryable().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false); + deviceOptions = await dbContext.DeviceOptions.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false); if (deviceOptions is null) { deviceOptions = new DeviceOptions(deviceId); @@ -132,22 +132,11 @@ namespace Jellyfin.Server.Implementations.Devices var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var devices = dbContext.Devices.AsQueryable(); - - if (query.UserId.HasValue) - { - devices = devices.Where(device => device.UserId.Equals(query.UserId.Value)); - } - - if (query.DeviceId is not null) - { - devices = devices.Where(device => device.DeviceId == query.DeviceId); - } - - if (query.AccessToken is not null) - { - devices = devices.Where(device => device.AccessToken == query.AccessToken); - } + var devices = dbContext.Devices + .OrderBy(d => d.Id) + .Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value)) + .Where(device => query.DeviceId == null || device.DeviceId == query.DeviceId) + .Where(device => query.AccessToken == null || device.AccessToken == query.AccessToken); var count = await devices.CountAsync().ConfigureAwait(false); @@ -179,11 +168,10 @@ namespace Jellyfin.Server.Implementations.Devices /// <inheritdoc /> public async Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId, bool? supportsSync) { - IAsyncEnumerable<Device> sessions; var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - sessions = dbContext.Devices + IAsyncEnumerable<Device> sessions = dbContext.Devices .Include(d => d.User) .OrderByDescending(d => d.DateLastActivity) .ThenBy(d => d.DeviceId) diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index 05c622931..bb8d4dd14 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using EFCoreSecondLevelCacheInterceptor; using MediaBrowser.Common.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Implementations.Extensions; @@ -29,13 +28,11 @@ public static class ServiceCollectionExtensions .SkipCachingResults(result => result.Value is null || (result.Value is EFTableRows rows && rows.RowsCount == 0))); - serviceCollection.AddPooledDbContextFactory<JellyfinDb>((serviceProvider, opt) => + serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) => { var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>(); - var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>(); opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}") - .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()) - .UseLoggerFactory(loggerFactory); + .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()); }); return serviceCollection; diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index c2c3513ad..b078db016 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -6,13 +6,9 @@ <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors> - </PropertyGroup> - <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs deleted file mode 100644 index dc4f53913..000000000 --- a/Jellyfin.Server.Implementations/JellyfinDb.cs +++ /dev/null @@ -1,162 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using System.Linq; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Entities.Security; -using Jellyfin.Data.Interfaces; -using Microsoft.EntityFrameworkCore; - -namespace Jellyfin.Server.Implementations -{ - /// <inheritdoc/> - public class JellyfinDb : DbContext - { - /// <summary> - /// Initializes a new instance of the <see cref="JellyfinDb"/> class. - /// </summary> - /// <param name="options">The database context options.</param> - public JellyfinDb(DbContextOptions<JellyfinDb> options) : base(options) - { - } - - /// <summary> - /// Gets or sets the default connection string. - /// </summary> - public static string ConnectionString { get; set; } = @"Data Source=jellyfin.db"; - - public virtual DbSet<AccessSchedule> AccessSchedules { get; set; } - - public virtual DbSet<ActivityLog> ActivityLogs { get; set; } - - public virtual DbSet<ApiKey> ApiKeys { get; set; } - - public virtual DbSet<Device> Devices { get; set; } - - public virtual DbSet<DeviceOptions> DeviceOptions { get; set; } - - public virtual DbSet<DisplayPreferences> DisplayPreferences { get; set; } - - public virtual DbSet<ImageInfo> ImageInfos { get; set; } - - public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; } - - public virtual DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences { get; set; } - - public virtual DbSet<Permission> Permissions { get; set; } - - public virtual DbSet<Preference> Preferences { get; set; } - - public virtual DbSet<User> Users { get; set; } - - /*public virtual DbSet<Artwork> Artwork { get; set; } - - public virtual DbSet<Book> Books { get; set; } - - public virtual DbSet<BookMetadata> BookMetadata { get; set; } - - public virtual DbSet<Chapter> Chapters { get; set; } - - public virtual DbSet<Collection> Collections { get; set; } - - public virtual DbSet<CollectionItem> CollectionItems { get; set; } - - public virtual DbSet<Company> Companies { get; set; } - - public virtual DbSet<CompanyMetadata> CompanyMetadata { get; set; } - - public virtual DbSet<CustomItem> CustomItems { get; set; } - - public virtual DbSet<CustomItemMetadata> CustomItemMetadata { get; set; } - - public virtual DbSet<Episode> Episodes { get; set; } - - public virtual DbSet<EpisodeMetadata> EpisodeMetadata { get; set; } - - public virtual DbSet<Genre> Genres { get; set; } - - public virtual DbSet<Group> Groups { get; set; } - - public virtual DbSet<Library> Libraries { get; set; } - - public virtual DbSet<LibraryItem> LibraryItems { get; set; } - - public virtual DbSet<LibraryRoot> LibraryRoot { get; set; } - - public virtual DbSet<MediaFile> MediaFiles { get; set; } - - public virtual DbSet<MediaFileStream> MediaFileStream { get; set; } - - public virtual DbSet<Metadata> Metadata { get; set; } - - public virtual DbSet<MetadataProvider> MetadataProviders { get; set; } - - public virtual DbSet<MetadataProviderId> MetadataProviderIds { get; set; } - - public virtual DbSet<Movie> Movies { get; set; } - - public virtual DbSet<MovieMetadata> MovieMetadata { get; set; } - - public virtual DbSet<MusicAlbum> MusicAlbums { get; set; } - - public virtual DbSet<MusicAlbumMetadata> MusicAlbumMetadata { get; set; } - - public virtual DbSet<Person> People { get; set; } - - public virtual DbSet<PersonRole> PersonRoles { get; set; } - - public virtual DbSet<Photo> Photo { get; set; } - - public virtual DbSet<PhotoMetadata> PhotoMetadata { get; set; } - - public virtual DbSet<ProviderMapping> ProviderMappings { get; set; } - - public virtual DbSet<Rating> Ratings { get; set; } - - /// <summary> - /// Repository for global::Jellyfin.Data.Entities.RatingSource - This is the entity to - /// store review ratings, not age ratings. - /// </summary> - public virtual DbSet<RatingSource> RatingSources { get; set; } - - public virtual DbSet<Release> Releases { get; set; } - - public virtual DbSet<Season> Seasons { get; set; } - - public virtual DbSet<SeasonMetadata> SeasonMetadata { get; set; } - - public virtual DbSet<Series> Series { get; set; } - - public virtual DbSet<SeriesMetadata> SeriesMetadata { get; set; } - - public virtual DbSet<Track> Tracks { get; set; } - - public virtual DbSet<TrackMetadata> TrackMetadata { get; set; }*/ - - /// <inheritdoc/> - public override int SaveChanges() - { - foreach (var saveEntity in ChangeTracker.Entries() - .Where(e => e.State == EntityState.Modified) - .Select(entry => entry.Entity) - .OfType<IHasConcurrencyToken>()) - { - saveEntity.OnSavingChanges(); - } - - return base.SaveChanges(); - } - - /// <inheritdoc /> - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); - base.OnModelCreating(modelBuilder); - modelBuilder.HasDefaultSchema("jellyfin"); - - // Configuration for each entity is in it's own class inside 'ModelConfiguration'. - modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDb).Assembly); - } - } -} diff --git a/Jellyfin.Server.Implementations/JellyfinDbContext.cs b/Jellyfin.Server.Implementations/JellyfinDbContext.cs new file mode 100644 index 000000000..0d91707e3 --- /dev/null +++ b/Jellyfin.Server.Implementations/JellyfinDbContext.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.Security; +using Jellyfin.Data.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Implementations; + +/// <inheritdoc/> +public class JellyfinDbContext : DbContext +{ + /// <summary> + /// Initializes a new instance of the <see cref="JellyfinDbContext"/> class. + /// </summary> + /// <param name="options">The database context options.</param> + public JellyfinDbContext(DbContextOptions<JellyfinDbContext> options) : base(options) + { + } + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the access schedules. + /// </summary> + public DbSet<AccessSchedule> AccessSchedules => Set<AccessSchedule>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the activity logs. + /// </summary> + public DbSet<ActivityLog> ActivityLogs => Set<ActivityLog>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the API keys. + /// </summary> + public DbSet<ApiKey> ApiKeys => Set<ApiKey>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the devices. + /// </summary> + public DbSet<Device> Devices => Set<Device>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the device options. + /// </summary> + public DbSet<DeviceOptions> DeviceOptions => Set<DeviceOptions>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the display preferences. + /// </summary> + public DbSet<DisplayPreferences> DisplayPreferences => Set<DisplayPreferences>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the image infos. + /// </summary> + public DbSet<ImageInfo> ImageInfos => Set<ImageInfo>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the item display preferences. + /// </summary> + public DbSet<ItemDisplayPreferences> ItemDisplayPreferences => Set<ItemDisplayPreferences>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the custom item display preferences. + /// </summary> + public DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences => Set<CustomItemDisplayPreferences>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the permissions. + /// </summary> + public DbSet<Permission> Permissions => Set<Permission>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the preferences. + /// </summary> + public DbSet<Preference> Preferences => Set<Preference>(); + + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the users. + /// </summary> + public DbSet<User> Users => Set<User>(); + + /*public DbSet<Artwork> Artwork => Set<Artwork>(); + + public DbSet<Book> Books => Set<Book>(); + + public DbSet<BookMetadata> BookMetadata => Set<BookMetadata>(); + + public DbSet<Chapter> Chapters => Set<Chapter>(); + + public DbSet<Collection> Collections => Set<Collection>(); + + public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>(); + + public DbSet<Company> Companies => Set<Company>(); + + public DbSet<CompanyMetadata> CompanyMetadata => Set<CompanyMetadata>(); + + public DbSet<CustomItem> CustomItems => Set<CustomItem>(); + + public DbSet<CustomItemMetadata> CustomItemMetadata => Set<CustomItemMetadata>(); + + public DbSet<Episode> Episodes => Set<Episode>(); + + public DbSet<EpisodeMetadata> EpisodeMetadata => Set<EpisodeMetadata>(); + + public DbSet<Genre> Genres => Set<Genre>(); + + public DbSet<Group> Groups => Set<Groups>(); + + public DbSet<Library> Libraries => Set<Library>(); + + public DbSet<LibraryItem> LibraryItems => Set<LibraryItems>(); + + public DbSet<LibraryRoot> LibraryRoot => Set<LibraryRoot>(); + + public DbSet<MediaFile> MediaFiles => Set<MediaFiles>(); + + public DbSet<MediaFileStream> MediaFileStream => Set<MediaFileStream>(); + + public DbSet<Metadata> Metadata => Set<Metadata>(); + + public DbSet<MetadataProvider> MetadataProviders => Set<MetadataProvider>(); + + public DbSet<MetadataProviderId> MetadataProviderIds => Set<MetadataProviderId>(); + + public DbSet<Movie> Movies => Set<Movie>(); + + public DbSet<MovieMetadata> MovieMetadata => Set<MovieMetadata>(); + + public DbSet<MusicAlbum> MusicAlbums => Set<MusicAlbum>(); + + public DbSet<MusicAlbumMetadata> MusicAlbumMetadata => Set<MusicAlbumMetadata>(); + + public DbSet<Person> People => Set<Person>(); + + public DbSet<PersonRole> PersonRoles => Set<PersonRole>(); + + public DbSet<Photo> Photo => Set<Photo>(); + + public DbSet<PhotoMetadata> PhotoMetadata => Set<PhotoMetadata>(); + + public DbSet<ProviderMapping> ProviderMappings => Set<ProviderMapping>(); + + public DbSet<Rating> Ratings => Set<Rating>(); + + /// <summary> + /// Repository for global::Jellyfin.Data.Entities.RatingSource - This is the entity to + /// store review ratings, not age ratings. + /// </summary> + public DbSet<RatingSource> RatingSources => Set<RatingSource>(); + + public DbSet<Release> Releases => Set<Release>(); + + public DbSet<Season> Seasons => Set<Season>(); + + public DbSet<SeasonMetadata> SeasonMetadata => Set<SeasonMetadata>(); + + public DbSet<Series> Series => Set<Series>(); + + public DbSet<SeriesMetadata> SeriesMetadata => Set<SeriesMetadata(); + + public DbSet<Track> Tracks => Set<Track>(); + + public DbSet<TrackMetadata> TrackMetadata => Set<TrackMetadata>();*/ + + /// <inheritdoc/> + public override int SaveChanges() + { + foreach (var saveEntity in ChangeTracker.Entries() + .Where(e => e.State == EntityState.Modified) + .Select(entry => entry.Entity) + .OfType<IHasConcurrencyToken>()) + { + saveEntity.OnSavingChanges(); + } + + return base.SaveChanges(); + } + + /// <inheritdoc /> + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc); + base.OnModelCreating(modelBuilder); + + // Configuration for each entity is in it's own class inside 'ModelConfiguration'. + modelBuilder.ApplyConfigurationsFromAssembly(typeof(JellyfinDbContext).Assembly); + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs index 98a83b745..4be6c2faa 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200514181226_AddActivityLog")] partial class AddActivityLog { diff --git a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs index 6342ce9cf..f3254734a 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200613202153_AddUsers")] partial class AddUsers { diff --git a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs index d44707d06..12d6faa8f 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200728005145_AddDisplayPreferences")] partial class AddDisplayPreferences { diff --git a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs index 2234f9d5f..f1cc20805 100644 --- a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20200905220533_FixDisplayPreferencesIndex")] partial class FixDisplayPreferencesIndex { diff --git a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs index e5c326a32..f134d363c 100644 --- a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20201004171403_AddMaxActiveSessions")] partial class AddMaxActiveSessions { diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs index 10663d065..ec65205d4 100644 --- a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20201204223655_AddCustomDisplayPreferences")] partial class AddCustomDisplayPreferences { diff --git a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs index 869676824..45dad6be6 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20210320181425_AddIndexesAndCollations.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20210320181425_AddIndexesAndCollations")] partial class AddIndexesAndCollations { diff --git a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs index d332d19f2..eff84b457 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20210407110544_NullableCustomPrefValue.Designer.cs @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20210407110544_NullableCustomPrefValue")] partial class NullableCustomPrefValue { diff --git a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs index 7e9566e2e..ad7c2dd2c 100644 --- a/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20210814002109_AddDevices.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20210814002109_AddDevices")] partial class AddDevices { diff --git a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs index 03e3f3c92..f9497a3b6 100644 --- a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs +++ b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] [Migration("20221022080052_AddIndexActivityLogsDateCreated")] partial class AddIndexActivityLogsDateCreated { diff --git a/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs b/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs index 72a4a8c3b..940cf7c5d 100644 --- a/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs +++ b/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs @@ -4,17 +4,17 @@ using Microsoft.EntityFrameworkCore.Design; namespace Jellyfin.Server.Implementations.Migrations { /// <summary> - /// The design time factory for <see cref="JellyfinDb"/>. + /// The design time factory for <see cref="JellyfinDbContext"/>. /// This is only used for the creation of migrations and not during runtime. /// </summary> - internal class DesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDb> + internal class DesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDbContext> { - public JellyfinDb CreateDbContext(string[] args) + public JellyfinDbContext CreateDbContext(string[] args) { - var optionsBuilder = new DbContextOptionsBuilder<JellyfinDb>(); + var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>(); optionsBuilder.UseSqlite("Data Source=jellyfin.db"); - return new JellyfinDb(optionsBuilder.Options); + return new JellyfinDbContext(optionsBuilder.Options); } } } diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index 2dd7b094a..dd5f7f012 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Jellyfin.Server.Implementations.Migrations { - [DbContext(typeof(JellyfinDb))] + [DbContext(typeof(JellyfinDbContext))] partial class JellyfinDbModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs index 810e57807..b2dfe60a1 100644 --- a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs +++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs @@ -10,13 +10,13 @@ namespace Jellyfin.Server.Implementations.Security /// <inheritdoc /> public class AuthenticationManager : IAuthenticationManager { - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; /// <summary> /// Initializes a new instance of the <see cref="AuthenticationManager"/> class. /// </summary> /// <param name="dbProvider">The database provider.</param> - public AuthenticationManager(IDbContextFactory<JellyfinDb> dbProvider) + public AuthenticationManager(IDbContextFactory<JellyfinDbContext> dbProvider) { _dbProvider = dbProvider; } @@ -40,7 +40,6 @@ namespace Jellyfin.Server.Implementations.Security await using (dbContext.ConfigureAwait(false)) { return await dbContext.ApiKeys - .AsAsyncEnumerable() .Select(key => new AuthenticationInfo { AppName = key.Name, @@ -60,7 +59,6 @@ namespace Jellyfin.Server.Implementations.Security await using (dbContext.ConfigureAwait(false)) { var key = await dbContext.ApiKeys - .AsQueryable() .Where(apiKey => apiKey.AccessToken == accessToken) .FirstOrDefaultAsync() .ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index ec5742bab..63d3e8a04 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -16,12 +16,12 @@ namespace Jellyfin.Server.Implementations.Security { public class AuthorizationContext : IAuthorizationContext { - private readonly IDbContextFactory<JellyfinDb> _jellyfinDbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _jellyfinDbProvider; private readonly IUserManager _userManager; private readonly IServerApplicationHost _serverApplicationHost; public AuthorizationContext( - IDbContextFactory<JellyfinDb> jellyfinDb, + IDbContextFactory<JellyfinDbContext> jellyfinDb, IUserManager userManager, IServerApplicationHost serverApplicationHost) { diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 4fda8f5a4..960195467 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -54,7 +54,8 @@ namespace Jellyfin.Server.Implementations.Users foreach (var resetFile in Directory.EnumerateFiles(_passwordResetFileBaseDir, $"{BaseResetFileName}*")) { SerializablePasswordReset spr; - await using (var str = AsyncFile.OpenRead(resetFile)) + var str = AsyncFile.OpenRead(resetFile); + await using (str.ConfigureAwait(false)) { spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"Provided path ({resetFile}) is not valid."); @@ -107,7 +108,8 @@ namespace Jellyfin.Server.Implementations.Users UserName = user.Username }; - await using (FileStream fileStream = AsyncFile.OpenWrite(filePath)) + FileStream fileStream = AsyncFile.OpenWrite(filePath); + await using (fileStream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index fddad1c4f..bfae81e4c 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -15,13 +15,13 @@ namespace Jellyfin.Server.Implementations.Users /// </summary> public class DisplayPreferencesManager : IDisplayPreferencesManager { - private readonly JellyfinDb _dbContext; + private readonly JellyfinDbContext _dbContext; /// <summary> /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class. /// </summary> /// <param name="dbContextFactory">The database context factory.</param> - public DisplayPreferencesManager(IDbContextFactory<JellyfinDb> dbContextFactory) + public DisplayPreferencesManager(IDbContextFactory<JellyfinDbContext> dbContextFactory) { _dbContext = dbContextFactory.CreateDbContext(); } @@ -62,7 +62,6 @@ namespace Jellyfin.Server.Implementations.Users public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client) { return _dbContext.ItemDisplayPreferences - .AsQueryable() .Where(prefs => prefs.UserId.Equals(userId) && !prefs.ItemId.Equals(default) && string.Equals(prefs.Client, client)) .ToList(); } @@ -71,7 +70,6 @@ namespace Jellyfin.Server.Implementations.Users public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client) { return _dbContext.CustomItemDisplayPreferences - .AsQueryable() .Where(prefs => prefs.UserId.Equals(userId) && prefs.ItemId.Equals(itemId) && string.Equals(prefs.Client, client)) @@ -82,7 +80,6 @@ namespace Jellyfin.Server.Implementations.Users public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences) { var existingPrefs = _dbContext.CustomItemDisplayPreferences - .AsQueryable() .Where(prefs => prefs.UserId.Equals(userId) && prefs.ItemId.Equals(itemId) && string.Equals(prefs.Client, client)); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 19ac007b9..dc9d78857 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Users /// </summary> public class UserManager : IUserManager { - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IEventManager _eventManager; private readonly ICryptoProvider _cryptoProvider; private readonly INetworkManager _networkManager; @@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users /// <param name="imageProcessor">The image processor.</param> /// <param name="logger">The logger.</param> public UserManager( - IDbContextFactory<JellyfinDb> dbProvider, + IDbContextFactory<JellyfinDbContext> dbProvider, IEventManager eventManager, ICryptoProvider cryptoProvider, INetworkManager networkManager, @@ -85,6 +85,7 @@ namespace Jellyfin.Server.Implementations.Users _users = new ConcurrentDictionary<Guid, User>(); using var dbContext = _dbProvider.CreateDbContext(); foreach (var user in dbContext.Users + .AsSplitQuery() .Include(user => user.Permissions) .Include(user => user.Preferences) .Include(user => user.AccessSchedules) @@ -143,7 +144,6 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { if (await dbContext.Users - .AsQueryable() .AnyAsync(u => u.Username == newName && !u.Id.Equals(user.Id)) .ConfigureAwait(false)) { @@ -172,7 +172,7 @@ namespace Jellyfin.Server.Implementations.Users } } - internal async Task<User> CreateUserInternalAsync(string name, JellyfinDb dbContext) + internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext) { // TODO: Remove after user item data is migrated. var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false) @@ -886,7 +886,7 @@ namespace Jellyfin.Server.Implementations.Users await UpdateUserAsync(user).ConfigureAwait(false); } - private async Task UpdateUserInternalAsync(JellyfinDb dbContext, User user) + private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Update(user); _users[user.Id] = user; diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index d70b8f3ab..40cd5a044 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -107,7 +107,7 @@ namespace Jellyfin.Server yield return typeof(CoreAppHost).Assembly; // Jellyfin.Server.Implementations - yield return typeof(JellyfinDb).Assembly; + yield return typeof(JellyfinDbContext).Assembly; } /// <inheritdoc /> diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index e29167747..463ca7321 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; +using Jellyfin.Api.Middleware; using Jellyfin.Networking.Configuration; -using Jellyfin.Server.Middleware; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.OpenApi.Models; diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index f74152405..e9af1cf83 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -20,13 +20,13 @@ using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Auth.SyncPlayAccessPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; +using Jellyfin.Api.Formatters; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using Jellyfin.Networking.Configuration; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; -using Jellyfin.Server.Formatters; using MediaBrowser.Common.Net; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; diff --git a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs new file mode 100644 index 000000000..58d3e1b2d --- /dev/null +++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Net; +using Jellyfin.Server.Helpers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Extensions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Extensions; + +/// <summary> +/// Extensions for configuring the web host builder. +/// </summary> +public static class WebHostBuilderExtensions +{ + /// <summary> + /// Configure the web host builder. + /// </summary> + /// <param name="builder">The builder to configure.</param> + /// <param name="appHost">The application host.</param> + /// <param name="startupConfig">The application configuration.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="logger">The logger.</param> + /// <returns>The configured web host builder.</returns> + public static IWebHostBuilder ConfigureWebHostBuilder( + this IWebHostBuilder builder, + CoreAppHost appHost, + IConfiguration startupConfig, + IApplicationPaths appPaths, + ILogger logger) + { + return builder + .UseKestrel((builderContext, options) => + { + var addresses = appHost.NetManager.GetAllBindInterfaces(); + + bool flagged = false; + foreach (IPObject netAdd in addresses) + { + logger.LogInformation("Kestrel listening on {Address}", IPAddress.IPv6Any.Equals(netAdd.Address) ? "All Addresses" : netAdd); + options.Listen(netAdd.Address, appHost.HttpPort); + if (appHost.ListenWithHttps) + { + options.Listen( + netAdd.Address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps(appHost.Certificate)); + } + else if (builderContext.HostingEnvironment.IsDevelopment()) + { + try + { + options.Listen( + netAdd.Address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps()); + } + catch (InvalidOperationException) + { + if (!flagged) + { + logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted"); + flagged = true; + } + } + } + } + + // Bind to unix socket (only on unix systems) + if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) + { + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); + + // Workaround for https://github.com/aspnet/AspNetCore/issues/14134 + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + + options.ListenUnixSocket(socketPath); + logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); + } + }) + .UseStartup(_ => new Startup(appHost)); + } +} diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs new file mode 100644 index 000000000..f1bb9b283 --- /dev/null +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -0,0 +1,326 @@ +using System; +using System.Globalization; +using System.IO; +using System.Net; +using System.Runtime.Versioning; +using System.Text; +using System.Threading.Tasks; +using Emby.Server.Implementations; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Extensions; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Serilog; +using SQLitePCL; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Jellyfin.Server.Helpers; + +/// <summary> +/// A class containing helper methods for server startup. +/// </summary> +public static class StartupHelpers +{ + /// <summary> + /// Create the data, config and log paths from the variety of inputs(command line args, + /// environment variables) or decide on what default to use. For Windows it's %AppPath% + /// for everything else the + /// <a href="https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html">XDG approach</a> + /// is followed. + /// </summary> + /// <param name="options">The <see cref="StartupOptions" /> for this instance.</param> + /// <returns><see cref="ServerApplicationPaths" />.</returns> + public static ServerApplicationPaths CreateApplicationPaths(StartupOptions options) + { + // dataDir + // IF --datadir + // ELSE IF $JELLYFIN_DATA_DIR + // ELSE IF windows, use <%APPDATA%>/jellyfin + // ELSE IF $XDG_DATA_HOME then use $XDG_DATA_HOME/jellyfin + // ELSE use $HOME/.local/share/jellyfin + var dataDir = options.DataDir; + if (string.IsNullOrEmpty(dataDir)) + { + dataDir = Environment.GetEnvironmentVariable("JELLYFIN_DATA_DIR"); + + if (string.IsNullOrEmpty(dataDir)) + { + // LocalApplicationData follows the XDG spec on unix machines + dataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "jellyfin"); + } + } + + // configDir + // IF --configdir + // ELSE IF $JELLYFIN_CONFIG_DIR + // ELSE IF --datadir, use <datadir>/config (assume portable run) + // ELSE IF <datadir>/config exists, use that + // ELSE IF windows, use <datadir>/config + // ELSE IF $XDG_CONFIG_HOME use $XDG_CONFIG_HOME/jellyfin + // ELSE $HOME/.config/jellyfin + var configDir = options.ConfigDir; + if (string.IsNullOrEmpty(configDir)) + { + configDir = Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR"); + + if (string.IsNullOrEmpty(configDir)) + { + if (options.DataDir is not null + || Directory.Exists(Path.Combine(dataDir, "config")) + || OperatingSystem.IsWindows()) + { + // Hang config folder off already set dataDir + configDir = Path.Combine(dataDir, "config"); + } + else + { + // $XDG_CONFIG_HOME defines the base directory relative to which + // user specific configuration files should be stored. + configDir = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + + // If $XDG_CONFIG_HOME is either not set or empty, + // a default equal to $HOME /.config should be used. + if (string.IsNullOrEmpty(configDir)) + { + configDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config"); + } + + configDir = Path.Combine(configDir, "jellyfin"); + } + } + } + + // cacheDir + // IF --cachedir + // ELSE IF $JELLYFIN_CACHE_DIR + // ELSE IF windows, use <datadir>/cache + // ELSE IF XDG_CACHE_HOME, use $XDG_CACHE_HOME/jellyfin + // ELSE HOME/.cache/jellyfin + var cacheDir = options.CacheDir; + if (string.IsNullOrEmpty(cacheDir)) + { + cacheDir = Environment.GetEnvironmentVariable("JELLYFIN_CACHE_DIR"); + + if (string.IsNullOrEmpty(cacheDir)) + { + if (OperatingSystem.IsWindows()) + { + // Hang cache folder off already set dataDir + cacheDir = Path.Combine(dataDir, "cache"); + } + else + { + // $XDG_CACHE_HOME defines the base directory relative to which + // user specific non-essential data files should be stored. + cacheDir = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + + // If $XDG_CACHE_HOME is either not set or empty, + // a default equal to $HOME/.cache should be used. + if (string.IsNullOrEmpty(cacheDir)) + { + cacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cache"); + } + + cacheDir = Path.Combine(cacheDir, "jellyfin"); + } + } + } + + // webDir + // IF --webdir + // ELSE IF $JELLYFIN_WEB_DIR + // ELSE <bindir>/jellyfin-web + var webDir = options.WebDir; + if (string.IsNullOrEmpty(webDir)) + { + webDir = Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR"); + + if (string.IsNullOrEmpty(webDir)) + { + // Use default location under ResourcesPath + webDir = Path.Combine(AppContext.BaseDirectory, "jellyfin-web"); + } + } + + // logDir + // IF --logdir + // ELSE IF $JELLYFIN_LOG_DIR + // ELSE IF --datadir, use <datadir>/log (assume portable run) + // ELSE <datadir>/log + var logDir = options.LogDir; + if (string.IsNullOrEmpty(logDir)) + { + logDir = Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR"); + + if (string.IsNullOrEmpty(logDir)) + { + // Hang log folder off already set dataDir + logDir = Path.Combine(dataDir, "log"); + } + } + + // Normalize paths. Only possible with GetFullPath for now - https://github.com/dotnet/runtime/issues/2162 + dataDir = Path.GetFullPath(dataDir); + logDir = Path.GetFullPath(logDir); + configDir = Path.GetFullPath(configDir); + cacheDir = Path.GetFullPath(cacheDir); + webDir = Path.GetFullPath(webDir); + + // Ensure the main folders exist before we continue + try + { + Directory.CreateDirectory(dataDir); + Directory.CreateDirectory(logDir); + Directory.CreateDirectory(configDir); + Directory.CreateDirectory(cacheDir); + } + catch (IOException ex) + { + Console.Error.WriteLine("Error whilst attempting to create folder"); + Console.Error.WriteLine(ex.ToString()); + Environment.Exit(1); + } + + return new ServerApplicationPaths(dataDir, logDir, configDir, cacheDir, webDir); + } + + /// <summary> + /// Gets the path for the unix socket Kestrel should bind to. + /// </summary> + /// <param name="startupConfig">The startup config.</param> + /// <param name="appPaths">The application paths.</param> + /// <returns>The path for Kestrel to bind to.</returns> + public static string GetUnixSocketPath(IConfiguration startupConfig, IApplicationPaths appPaths) + { + var socketPath = startupConfig.GetUnixSocketPath(); + + if (string.IsNullOrEmpty(socketPath)) + { + var xdgRuntimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + var socketFile = "jellyfin.sock"; + if (xdgRuntimeDir is null) + { + // Fall back to config dir + socketPath = Path.Join(appPaths.ConfigurationDirectoryPath, socketFile); + } + else + { + socketPath = Path.Join(xdgRuntimeDir, socketFile); + } + } + + return socketPath; + } + + /// <summary> + /// Sets the unix file permissions for Kestrel's socket file. + /// </summary> + /// <param name="startupConfig">The startup config.</param> + /// <param name="socketPath">The socket path.</param> + /// <param name="logger">The logger.</param> + [UnsupportedOSPlatform("windows")] + public static void SetUnixSocketPermissions(IConfiguration startupConfig, string socketPath, ILogger logger) + { + var socketPerms = startupConfig.GetUnixSocketPermissions(); + + if (!string.IsNullOrEmpty(socketPerms)) + { + File.SetUnixFileMode(socketPath, (UnixFileMode)Convert.ToInt32(socketPerms, 8)); + logger.LogInformation("Kestrel unix socket permissions set to {SocketPerms}", socketPerms); + } + } + + /// <summary> + /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist + /// already. + /// </summary> + /// <param name="appPaths">The application paths.</param> + /// <returns>A task representing the creation of the configuration file, or a completed task if the file already exists.</returns> + public static async Task InitLoggingConfigFile(IApplicationPaths appPaths) + { + // Do nothing if the config file already exists + string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, Program.LoggingConfigFileDefault); + if (File.Exists(configPath)) + { + return; + } + + // Get a stream of the resource contents + // NOTE: The .csproj name is used instead of the assembly name in the resource path + const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json"; + Stream resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) + ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); + await using (resource.ConfigureAwait(false)) + { + Stream dst = new FileStream(configPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await using (dst.ConfigureAwait(false)) + { + // Copy the resource contents to the expected file path for the config file + await resource.CopyToAsync(dst).ConfigureAwait(false); + } + } + } + + /// <summary> + /// Initialize Serilog using configuration and fall back to defaults on failure. + /// </summary> + /// <param name="configuration">The configuration object.</param> + /// <param name="appPaths">The application paths.</param> + public static void InitializeLoggingFramework(IConfiguration configuration, IApplicationPaths appPaths) + { + try + { + // Serilog.Log is used by SerilogLoggerFactory when no logger is specified + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .Enrich.FromLogContext() + .Enrich.WithThreadId() + .CreateLogger(); + } + catch (Exception ex) + { + Log.Logger = new LoggerConfiguration() + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", + formatProvider: CultureInfo.InvariantCulture) + .WriteTo.Async(x => x.File( + Path.Combine(appPaths.LogDirectoryPath, "log_.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", + formatProvider: CultureInfo.InvariantCulture, + encoding: Encoding.UTF8)) + .Enrich.FromLogContext() + .Enrich.WithThreadId() + .CreateLogger(); + + Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); + } + } + + /// <summary> + /// Call static initialization methods for the application. + /// </summary> + public static void PerformStaticInitialization() + { + // 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; + + Batteries_V2.Init(); + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 1cd65ea82..9ea8508f2 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -24,7 +24,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> @@ -41,20 +41,20 @@ <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.2" /> <PackageReference Include="prometheus-net" Version="7.0.0" /> <PackageReference Include="prometheus-net.AspNetCore" Version="7.0.0" /> - <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> + <PackageReference Include="Serilog.AspNetCore" Version="6.1.0" /> <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" /> <PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" /> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageReference Include="Serilog.Sinks.Graylog" Version="2.3.0" /> - <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.3" /> + <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.4" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\src\Jellyfin.Drawing\Jellyfin.Drawing.csproj" /> <ProjectReference Include="..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" /> - <ProjectReference Include="..\Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj" /> + <ProjectReference Include="..\src\Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj" /> <ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" /> <ProjectReference Include="..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" /> </ItemGroup> diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index e9a45c140..23fb9e370 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -38,7 +38,6 @@ namespace Jellyfin.Server.Migrations typeof(Routines.ReaddDefaultPluginRepository), typeof(Routines.MigrateDisplayPreferencesDb), typeof(Routines.RemoveDownloadImagesInAdvance), - typeof(Routines.AddPeopleQueryIndex), typeof(Routines.MigrateAuthenticationDb) }; diff --git a/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs b/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs deleted file mode 100644 index 6343c422d..000000000 --- a/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.IO; -using MediaBrowser.Controller; -using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; - -namespace Jellyfin.Server.Migrations.Routines -{ - /// <summary> - /// Migration to add table indexes to optimize the Persons query. - /// </summary> - public class AddPeopleQueryIndex : IMigrationRoutine - { - private const string DbFilename = "library.db"; - private readonly ILogger<AddPeopleQueryIndex> _logger; - private readonly IServerApplicationPaths _serverApplicationPaths; - - /// <summary> - /// Initializes a new instance of the <see cref="AddPeopleQueryIndex"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{AddPeopleQueryIndex}"/> interface.</param> - /// <param name="serverApplicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> - public AddPeopleQueryIndex(ILogger<AddPeopleQueryIndex> logger, IServerApplicationPaths serverApplicationPaths) - { - _logger = logger; - _serverApplicationPaths = serverApplicationPaths; - } - - /// <inheritdoc /> - public Guid Id => new Guid("DE009B59-BAAE-428D-A810-F67762DC05B8"); - - /// <inheritdoc /> - public string Name => "AddPeopleQueryIndex"; - - /// <inheritdoc /> - public bool PerformOnNewInstall => true; - - /// <inheritdoc /> - public void Perform() - { - var databasePath = Path.Join(_serverApplicationPaths.DataPath, DbFilename); - using var connection = SQLite3.Open(databasePath, ConnectionFlags.ReadWrite, null); - _logger.LogInformation("Creating index idx_TypedBaseItemsUserDataKeyType"); - connection.Execute("CREATE INDEX IF NOT EXISTS idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type);"); - _logger.LogInformation("Creating index idx_PeopleNameListOrder"); - connection.Execute("CREATE INDEX IF NOT EXISTS idx_PeopleNameListOrder ON People(Name, ListOrder);"); - } - } -} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index bf66f75ff..e8a0af9f8 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -19,7 +19,7 @@ namespace Jellyfin.Server.Migrations.Routines private const string DbFilename = "activitylog.db"; private readonly ILogger<MigrateActivityLogDb> _logger; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; private readonly IServerApplicationPaths _paths; /// <summary> @@ -28,7 +28,7 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="logger">The logger.</param> /// <param name="paths">The server application paths.</param> /// <param name="provider">The database provider.</param> - public MigrateActivityLogDb(ILogger<MigrateActivityLogDb> logger, IServerApplicationPaths paths, IDbContextFactory<JellyfinDb> provider) + public MigrateActivityLogDb(ILogger<MigrateActivityLogDb> logger, IServerApplicationPaths paths, IDbContextFactory<JellyfinDbContext> provider) { _logger = logger; _provider = provider; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index bf1ea8233..09daae0ff 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -20,7 +20,7 @@ namespace Jellyfin.Server.Migrations.Routines private const string DbFilename = "authentication.db"; private readonly ILogger<MigrateAuthenticationDb> _logger; - private readonly IDbContextFactory<JellyfinDb> _dbProvider; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IServerApplicationPaths _appPaths; private readonly IUserManager _userManager; @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="userManager">The user manager.</param> public MigrateAuthenticationDb( ILogger<MigrateAuthenticationDb> logger, - IDbContextFactory<JellyfinDb> dbProvider, + IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationPaths appPaths, IUserManager userManager) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 0fad77cfe..4b692d14f 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -25,7 +25,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateDisplayPreferencesDb> _logger; private readonly IServerApplicationPaths _paths; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; private readonly JsonSerializerOptions _jsonOptions; private readonly IUserManager _userManager; @@ -39,7 +39,7 @@ namespace Jellyfin.Server.Migrations.Routines public MigrateDisplayPreferencesDb( ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, - IDbContextFactory<JellyfinDb> provider, + IDbContextFactory<JellyfinDbContext> provider, IUserManager userManager) { _logger = logger; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 2dbd82e8f..ea2f03302 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -27,7 +27,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateUserDb> _logger; private readonly IServerApplicationPaths _paths; - private readonly IDbContextFactory<JellyfinDb> _provider; + private readonly IDbContextFactory<JellyfinDbContext> _provider; private readonly IXmlSerializer _xmlSerializer; /// <summary> @@ -40,7 +40,7 @@ namespace Jellyfin.Server.Migrations.Routines public MigrateUserDb( ILogger<MigrateUserDb> logger, IServerApplicationPaths paths, - IDbContextFactory<JellyfinDb> provider, + IDbContextFactory<JellyfinDbContext> provider, IXmlSerializer xmlSerializer) { _logger = logger; diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 7052f4d2b..25fe30a39 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -1,33 +1,26 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; -using System.Net; using System.Reflection; -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using System.Text; using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; +using Jellyfin.Server.Extensions; +using Jellyfin.Server.Helpers; using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.IO; -using Microsoft.AspNetCore.Hosting; +using MediaBrowser.Controller; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Extensions.Logging; -using SQLitePCL; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -48,8 +41,9 @@ namespace Jellyfin.Server /// </summary> public const string LoggingConfigFileSystem = "logging.json"; - private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource(); private static readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory(); + private static CancellationTokenSource _tokenSource = new(); + private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; @@ -94,14 +88,14 @@ namespace Jellyfin.Server private static async Task StartApp(StartupOptions options) { - var startTimestamp = Stopwatch.GetTimestamp(); + _startTimestamp = Stopwatch.GetTimestamp(); // Log all uncaught exceptions to std error static void UnhandledExceptionToConsole(object sender, UnhandledExceptionEventArgs e) => - Console.Error.WriteLine("Unhandled Exception\n" + e.ExceptionObject.ToString()); + Console.Error.WriteLine("Unhandled Exception\n" + e.ExceptionObject); AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionToConsole; - ServerApplicationPaths appPaths = CreateApplicationPaths(options); + ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options); // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath); @@ -110,13 +104,12 @@ namespace Jellyfin.Server Environment.SetEnvironmentVariable("NEOReadDebugKeys", "1"); Environment.SetEnvironmentVariable("EnableExtendedVaFormats", "1"); - await InitLoggingConfigFile(appPaths).ConfigureAwait(false); + await StartupHelpers.InitLoggingConfigFile(appPaths).ConfigureAwait(false); // Create an instance of the application configuration to use for application startup IConfiguration startupConfig = CreateAppConfiguration(options, appPaths); - // Initialize logging framework - InitializeLoggingFramework(startupConfig, appPaths); + StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths); _logger = _loggerFactory.CreateLogger("Main"); // Log uncaught exceptions to the logging instead of std error @@ -160,14 +153,14 @@ namespace Jellyfin.Server // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) { - string? webContentPath = appPaths.WebPath; + var webContentPath = appPaths.WebPath; if (!Directory.Exists(webContentPath) || !Directory.EnumerateFiles(webContentPath).Any()) { _logger.LogError( "The server is expected to host the web client, but the provided content directory is either " + "invalid or empty: {WebContentPath}. If you do not want to host the web client with the " + "server, you may set the '--nowebclient' command line flag, or set" + - "'{ConfigKey}=false' in your config settings.", + "'{ConfigKey}=false' in your config settings", webContentPath, HostWebClientKey); Environment.ExitCode = 1; @@ -175,48 +168,66 @@ namespace Jellyfin.Server } } - PerformStaticInitialization(); + StartupHelpers.PerformStaticInitialization(); Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory); + do + { + _restartOnShutdown = false; + await StartServer(appPaths, options, startupConfig).ConfigureAwait(false); + + if (_restartOnShutdown) + { + _tokenSource = new CancellationTokenSource(); + _startTimestamp = Stopwatch.GetTimestamp(); + } + } while (_restartOnShutdown); + } + + private static async Task StartServer(IServerApplicationPaths appPaths, StartupOptions options, IConfiguration startupConfig) + { var appHost = new CoreAppHost( appPaths, _loggerFactory, options, startupConfig); + IHost? host = null; try { - var serviceCollection = new ServiceCollection(); - appHost.Init(serviceCollection); - - var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build(); + host = Host.CreateDefaultBuilder() + .ConfigureServices(services => appHost.Init(services)) + .ConfigureWebHostDefaults(webHostBuilder => webHostBuilder.ConfigureWebHostBuilder(appHost, startupConfig, appPaths, _logger)) + .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) + .UseSerilog() + .Build(); - // Re-use the web host service provider in the app host since ASP.NET doesn't allow a custom service collection. - appHost.ServiceProvider = webHost.Services; + // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. + appHost.ServiceProvider = host.Services; await appHost.InitializeServices().ConfigureAwait(false); Migrations.MigrationRunner.Run(appHost, _loggerFactory); try { - await webHost.StartAsync(_tokenSource.Token).ConfigureAwait(false); + await host.StartAsync(_tokenSource.Token).ConfigureAwait(false); if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) { - var socketPath = GetUnixSocketPath(startupConfig, appPaths); + var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths); - SetUnixSocketPermissions(startupConfig, socketPath); + StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); } } catch (Exception ex) when (ex is not TaskCanceledException) { - _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in network.xml and try again."); + _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in network.xml and try again"); throw; } await appHost.RunStartupTasksAsync(_tokenSource.Token).ConfigureAwait(false); - _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(startTimestamp)); + _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); // Block main thread until shutdown await Task.Delay(-1, _tokenSource.Token).ConfigureAwait(false); @@ -227,7 +238,7 @@ namespace Jellyfin.Server } catch (Exception ex) { - _logger.LogCritical(ex, "Error while starting server."); + _logger.LogCritical(ex, "Error while starting server"); } finally { @@ -236,7 +247,7 @@ namespace Jellyfin.Server { _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<JellyfinDb>>().CreateDbContextAsync().ConfigureAwait(false); + var context = await appHost.ServiceProvider.GetRequiredService<IDbContextFactory<JellyfinDbContext>>().CreateDbContextAsync().ConfigureAwait(false); await using (context.ConfigureAwait(false)) { if (context.Database.IsSqlite()) @@ -247,315 +258,7 @@ namespace Jellyfin.Server } await appHost.DisposeAsync().ConfigureAwait(false); - } - - if (_restartOnShutdown) - { - StartNewInstance(options); - } - } - - /// <summary> - /// Call static initialization methods for the application. - /// </summary> - public static void PerformStaticInitialization() - { - // 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; - - Batteries_V2.Init(); - if (raw.sqlite3_enable_shared_cache(1) != raw.SQLITE_OK) - { - _logger.LogWarning("Failed to enable shared cache for SQLite"); - } - } - - /// <summary> - /// Configure the web host builder. - /// </summary> - /// <param name="builder">The builder to configure.</param> - /// <param name="appHost">The application host.</param> - /// <param name="serviceCollection">The application service collection.</param> - /// <param name="commandLineOpts">The command line options passed to the application.</param> - /// <param name="startupConfig">The application configuration.</param> - /// <param name="appPaths">The application paths.</param> - /// <returns>The configured web host builder.</returns> - public static IWebHostBuilder ConfigureWebHostBuilder( - this IWebHostBuilder builder, - ApplicationHost appHost, - IServiceCollection serviceCollection, - StartupOptions commandLineOpts, - IConfiguration startupConfig, - IApplicationPaths appPaths) - { - return builder - .UseKestrel((builderContext, options) => - { - var addresses = appHost.NetManager.GetAllBindInterfaces(); - - bool flagged = false; - foreach (IPObject netAdd in addresses) - { - _logger.LogInformation("Kestrel listening on {Address}", netAdd.Address == IPAddress.IPv6Any ? "All Addresses" : netAdd); - options.Listen(netAdd.Address, appHost.HttpPort); - if (appHost.ListenWithHttps) - { - options.Listen( - netAdd.Address, - appHost.HttpsPort, - listenOptions => listenOptions.UseHttps(appHost.Certificate)); - } - else if (builderContext.HostingEnvironment.IsDevelopment()) - { - try - { - options.Listen( - netAdd.Address, - appHost.HttpsPort, - listenOptions => listenOptions.UseHttps()); - } - catch (InvalidOperationException) - { - if (!flagged) - { - _logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted."); - flagged = true; - } - } - } - } - - // Bind to unix socket (only on unix systems) - if (startupConfig.UseUnixSocket() && Environment.OSVersion.Platform == PlatformID.Unix) - { - var socketPath = GetUnixSocketPath(startupConfig, appPaths); - - // Workaround for https://github.com/aspnet/AspNetCore/issues/14134 - if (File.Exists(socketPath)) - { - File.Delete(socketPath); - } - - options.ListenUnixSocket(socketPath); - _logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); - } - }) - .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(commandLineOpts, appPaths, startupConfig)) - .UseSerilog() - .ConfigureServices(services => - { - // Merge the external ServiceCollection into ASP.NET DI - services.Add(serviceCollection); - }) - .UseStartup<Startup>(); - } - - /// <summary> - /// Create the data, config and log paths from the variety of inputs(command line args, - /// environment variables) or decide on what default to use. For Windows it's %AppPath% - /// for everything else the - /// <a href="https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html">XDG approach</a> - /// is followed. - /// </summary> - /// <param name="options">The <see cref="StartupOptions" /> for this instance.</param> - /// <returns><see cref="ServerApplicationPaths" />.</returns> - private static ServerApplicationPaths CreateApplicationPaths(StartupOptions options) - { - // dataDir - // IF --datadir - // ELSE IF $JELLYFIN_DATA_DIR - // ELSE IF windows, use <%APPDATA%>/jellyfin - // ELSE IF $XDG_DATA_HOME then use $XDG_DATA_HOME/jellyfin - // ELSE use $HOME/.local/share/jellyfin - var dataDir = options.DataDir; - if (string.IsNullOrEmpty(dataDir)) - { - dataDir = Environment.GetEnvironmentVariable("JELLYFIN_DATA_DIR"); - - if (string.IsNullOrEmpty(dataDir)) - { - // LocalApplicationData follows the XDG spec on unix machines - dataDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "jellyfin"); - } - } - - // configDir - // IF --configdir - // ELSE IF $JELLYFIN_CONFIG_DIR - // ELSE IF --datadir, use <datadir>/config (assume portable run) - // ELSE IF <datadir>/config exists, use that - // ELSE IF windows, use <datadir>/config - // ELSE IF $XDG_CONFIG_HOME use $XDG_CONFIG_HOME/jellyfin - // ELSE $HOME/.config/jellyfin - var configDir = options.ConfigDir; - if (string.IsNullOrEmpty(configDir)) - { - configDir = Environment.GetEnvironmentVariable("JELLYFIN_CONFIG_DIR"); - - if (string.IsNullOrEmpty(configDir)) - { - if (options.DataDir is not null - || Directory.Exists(Path.Combine(dataDir, "config")) - || OperatingSystem.IsWindows()) - { - // Hang config folder off already set dataDir - configDir = Path.Combine(dataDir, "config"); - } - else - { - // $XDG_CONFIG_HOME defines the base directory relative to which - // user specific configuration files should be stored. - configDir = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); - - // If $XDG_CONFIG_HOME is either not set or empty, - // a default equal to $HOME /.config should be used. - if (string.IsNullOrEmpty(configDir)) - { - configDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config"); - } - - configDir = Path.Combine(configDir, "jellyfin"); - } - } - } - - // cacheDir - // IF --cachedir - // ELSE IF $JELLYFIN_CACHE_DIR - // ELSE IF windows, use <datadir>/cache - // ELSE IF XDG_CACHE_HOME, use $XDG_CACHE_HOME/jellyfin - // ELSE HOME/.cache/jellyfin - var cacheDir = options.CacheDir; - if (string.IsNullOrEmpty(cacheDir)) - { - cacheDir = Environment.GetEnvironmentVariable("JELLYFIN_CACHE_DIR"); - - if (string.IsNullOrEmpty(cacheDir)) - { - if (OperatingSystem.IsWindows()) - { - // Hang cache folder off already set dataDir - cacheDir = Path.Combine(dataDir, "cache"); - } - else - { - // $XDG_CACHE_HOME defines the base directory relative to which - // user specific non-essential data files should be stored. - cacheDir = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); - - // If $XDG_CACHE_HOME is either not set or empty, - // a default equal to $HOME/.cache should be used. - if (string.IsNullOrEmpty(cacheDir)) - { - cacheDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cache"); - } - - cacheDir = Path.Combine(cacheDir, "jellyfin"); - } - } - } - - // webDir - // IF --webdir - // ELSE IF $JELLYFIN_WEB_DIR - // ELSE <bindir>/jellyfin-web - var webDir = options.WebDir; - if (string.IsNullOrEmpty(webDir)) - { - webDir = Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR"); - - if (string.IsNullOrEmpty(webDir)) - { - // Use default location under ResourcesPath - webDir = Path.Combine(AppContext.BaseDirectory, "jellyfin-web"); - } - } - - // logDir - // IF --logdir - // ELSE IF $JELLYFIN_LOG_DIR - // ELSE IF --datadir, use <datadir>/log (assume portable run) - // ELSE <datadir>/log - var logDir = options.LogDir; - if (string.IsNullOrEmpty(logDir)) - { - logDir = Environment.GetEnvironmentVariable("JELLYFIN_LOG_DIR"); - - if (string.IsNullOrEmpty(logDir)) - { - // Hang log folder off already set dataDir - logDir = Path.Combine(dataDir, "log"); - } - } - - // Normalize paths. Only possible with GetFullPath for now - https://github.com/dotnet/runtime/issues/2162 - dataDir = Path.GetFullPath(dataDir); - logDir = Path.GetFullPath(logDir); - configDir = Path.GetFullPath(configDir); - cacheDir = Path.GetFullPath(cacheDir); - webDir = Path.GetFullPath(webDir); - - // Ensure the main folders exist before we continue - try - { - Directory.CreateDirectory(dataDir); - Directory.CreateDirectory(logDir); - Directory.CreateDirectory(configDir); - Directory.CreateDirectory(cacheDir); - } - catch (IOException ex) - { - Console.Error.WriteLine("Error whilst attempting to create folder"); - Console.Error.WriteLine(ex.ToString()); - Environment.Exit(1); - } - - return new ServerApplicationPaths(dataDir, logDir, configDir, cacheDir, webDir); - } - - /// <summary> - /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist - /// already. - /// </summary> - /// <param name="appPaths">The application paths.</param> - /// <returns>A task representing the creation of the configuration file, or a completed task if the file already exists.</returns> - public static async Task InitLoggingConfigFile(IApplicationPaths appPaths) - { - // Do nothing if the config file already exists - string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, LoggingConfigFileDefault); - if (File.Exists(configPath)) - { - return; - } - - // Get a stream of the resource contents - // NOTE: The .csproj name is used instead of the assembly name in the resource path - const string ResourcePath = "Jellyfin.Server.Resources.Configuration.logging.json"; - Stream resource = typeof(Program).Assembly.GetManifestResourceStream(ResourcePath) - ?? throw new InvalidOperationException($"Invalid resource path: '{ResourcePath}'"); - await using (resource.ConfigureAwait(false)) - { - Stream dst = new FileStream(configPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await using (dst.ConfigureAwait(false)) - { - // Copy the resource contents to the expected file path for the config file - await resource.CopyToAsync(dst).ConfigureAwait(false); - } + host?.Dispose(); } } @@ -593,112 +296,5 @@ namespace Jellyfin.Server .AddEnvironmentVariables("JELLYFIN_") .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); } - - /// <summary> - /// Initialize Serilog using configuration and fall back to defaults on failure. - /// </summary> - private static void InitializeLoggingFramework(IConfiguration configuration, IApplicationPaths appPaths) - { - try - { - // Serilog.Log is used by SerilogLoggerFactory when no logger is specified - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .Enrich.FromLogContext() - .Enrich.WithThreadId() - .CreateLogger(); - } - catch (Exception ex) - { - Log.Logger = new LoggerConfiguration() - .WriteTo.Console( - outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", - formatProvider: CultureInfo.InvariantCulture) - .WriteTo.Async(x => x.File( - Path.Combine(appPaths.LogDirectoryPath, "log_.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", - formatProvider: CultureInfo.InvariantCulture, - encoding: Encoding.UTF8)) - .Enrich.FromLogContext() - .Enrich.WithThreadId() - .CreateLogger(); - - Log.Logger.Fatal(ex, "Failed to create/read logger configuration"); - } - } - - private static void StartNewInstance(StartupOptions options) - { - _logger.LogInformation("Starting new instance"); - - var module = options.RestartPath; - - if (string.IsNullOrWhiteSpace(module)) - { - module = Environment.GetCommandLineArgs()[0]; - } - - string commandLineArgsString; - if (options.RestartArgs is not null) - { - commandLineArgsString = options.RestartArgs; - } - else - { - commandLineArgsString = string.Join( - ' ', - Environment.GetCommandLineArgs().Skip(1).Select(NormalizeCommandLineArgument)); - } - - _logger.LogInformation("Executable: {0}", module); - _logger.LogInformation("Arguments: {0}", commandLineArgsString); - - Process.Start(module, commandLineArgsString); - } - - private static string NormalizeCommandLineArgument(string arg) - { - if (!arg.Contains(' ', StringComparison.Ordinal)) - { - return arg; - } - - return "\"" + arg + "\""; - } - - private static string GetUnixSocketPath(IConfiguration startupConfig, IApplicationPaths appPaths) - { - var socketPath = startupConfig.GetUnixSocketPath(); - - if (string.IsNullOrEmpty(socketPath)) - { - var xdgRuntimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); - var socketFile = "jellyfin.sock"; - if (xdgRuntimeDir is null) - { - // Fall back to config dir - socketPath = Path.Join(appPaths.ConfigurationDirectoryPath, socketFile); - } - else - { - socketPath = Path.Join(xdgRuntimeDir, socketFile); - } - } - - return socketPath; - } - - [UnsupportedOSPlatform("windows")] - private static void SetUnixSocketPermissions(IConfiguration startupConfig, string socketPath) - { - var socketPerms = startupConfig.GetUnixSocketPermissions(); - - if (!string.IsNullOrEmpty(socketPerms)) - { - File.SetUnixFileMode(socketPath, (UnixFileMode)Convert.ToInt32(socketPerms, 8)); - _logger.LogInformation("Kestrel unix socket permissions set to {SocketPerms}", socketPerms); - } - } } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 5d6a278c4..7abd2fbef 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; using System.Text; +using Jellyfin.Api.Middleware; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking.Configuration; using Jellyfin.Server.Extensions; @@ -12,7 +13,6 @@ using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations; using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Infrastructure; -using Jellyfin.Server.Middleware; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -35,20 +35,17 @@ namespace Jellyfin.Server /// </summary> public class Startup { - private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerApplicationHost _serverApplicationHost; + private readonly IServerConfigurationManager _serverConfigurationManager; /// <summary> /// Initializes a new instance of the <see cref="Startup" /> class. /// </summary> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <param name="serverApplicationHost">The server application host.</param> - public Startup( - IServerConfigurationManager serverConfigurationManager, - IServerApplicationHost serverApplicationHost) + /// <param name="appHost">The server application host.</param> + public Startup(CoreAppHost appHost) { - _serverConfigurationManager = serverConfigurationManager; - _serverApplicationHost = serverApplicationHost; + _serverApplicationHost = appHost; + _serverConfigurationManager = appHost.ConfigurationManager; } /// <summary> @@ -87,8 +84,7 @@ namespace Jellyfin.Server RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8 }; - services - .AddHttpClient(NamedClient.Default, c => + services.AddHttpClient(NamedClient.Default, c => { c.DefaultRequestHeaders.UserAgent.Add(productHeader); c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader); @@ -123,7 +119,7 @@ namespace Jellyfin.Server .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); services.AddHealthChecks() - .AddCheck<DbContextFactoryHealthCheck<JellyfinDb>>(nameof(JellyfinDb)); + .AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext)); services.AddHlsPlaylistGenerator(); } @@ -208,7 +204,7 @@ namespace Jellyfin.Server endpoints.MapControllers(); if (_serverConfigurationManager.Configuration.EnableMetrics) { - endpoints.MapMetrics("/metrics"); + endpoints.MapMetrics(); } endpoints.MapHealthChecks("/health"); diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index 0d9f379e0..c3989751c 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -64,14 +64,6 @@ namespace Jellyfin.Server public string? PackageName { get; set; } /// <inheritdoc /> - [Option("restartpath", Required = false, HelpText = "Path to restart script.")] - public string? RestartPath { get; set; } - - /// <inheritdoc /> - [Option("restartargs", Required = false, HelpText = "Arguments for restart script.")] - public string? RestartArgs { get; set; } - - /// <inheritdoc /> [Option("published-server-url", Required = false, HelpText = "Jellyfin Server URL to publish via auto discover process")] public string? PublishedServerUrl { get; set; } diff --git a/Jellyfin.sln b/Jellyfin.sln index 347716eb9..c0d2ec068 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -42,7 +42,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution SharedVersion.cs = SharedVersion.cs EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia", "Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia", "src\Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}" EndProject @@ -288,6 +288,7 @@ Global {FE47334C-EFDE-4519-BD50-F24430FF360B} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} {08FFF49B-F175-4807-A2B5-73B0EBD9F716} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/MediaBrowser.Common/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 53683cdbd..96ee701b3 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -48,12 +48,6 @@ namespace MediaBrowser.Common bool IsShuttingDown { get; } /// <summary> - /// Gets a value indicating whether this instance can self restart. - /// </summary> - /// <value><c>true</c> if this instance can self restart; otherwise, <c>false</c>.</value> - bool CanSelfRestart { get; } - - /// <summary> /// Gets the application version. /// </summary> /// <value>The application version.</value> diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 0296974b5..1b0ff27d9 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -49,7 +49,7 @@ <!-- Code analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/MediaBrowser.Common/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs index 176bcbbd5..fa92d383a 100644 --- a/MediaBrowser.Common/Plugins/IPluginManager.cs +++ b/MediaBrowser.Common/Plugins/IPluginManager.cs @@ -30,6 +30,11 @@ namespace MediaBrowser.Common.Plugins IEnumerable<Assembly> LoadAssemblies(); /// <summary> + /// Unloads all of the assemblies. + /// </summary> + void UnloadAssemblies(); + + /// <summary> /// Registers the plugin's services with the DI. /// Note: DI is not yet instantiated yet. /// </summary> diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 6434621c4..20909c9d5 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -51,7 +51,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index b40c224d5..e94a04a7d 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2129,15 +2129,30 @@ namespace MediaBrowser.Controller.MediaEncoding var filters = new List<string>(); - // Boost volume to 200% when downsampling from 6ch to 2ch if (channels.HasValue - && channels.Value <= 2 + && channels.Value == 2 && state.AudioStream is not null && state.AudioStream.Channels.HasValue - && state.AudioStream.Channels.Value > 5 - && !encodingOptions.DownMixAudioBoost.Equals(1)) + && state.AudioStream.Channels.Value > 5) { - filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture)); + switch (encodingOptions.DownMixStereoAlgorithm) + { + case DownMixStereoAlgorithms.Dave750: + filters.Add("volume=4.25"); + filters.Add("pan=stereo|c0=0.5*c2+0.707*c0+0.707*c4+0.5*c3|c1=0.5*c2+0.707*c1+0.707*c5+0.5*c3"); + break; + case DownMixStereoAlgorithms.NightmodeDialogue: + filters.Add("pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5"); + break; + case DownMixStereoAlgorithms.None: + default: + if (!encodingOptions.DownMixAudioBoost.Equals(1)) + { + filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture)); + } + + break; + } } var isCopyingTimestamps = state.CopyTimestamps || state.TranscodingType != TranscodingJobType.Progressive; @@ -5711,10 +5726,9 @@ namespace MediaBrowser.Controller.MediaEncoding return args; } - // Add the number of audio channels var channels = state.OutputAudioChannels; - if (channels.HasValue) + if (channels.HasValue && ((channels.Value != 2 && state.AudioStream.Channels <= 5) || encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None)) { args += " -ac " + channels.Value; } diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index de3987b1e..039127f9e 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -22,7 +22,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 91bf42b15..d95f894c5 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -498,11 +498,12 @@ namespace MediaBrowser.MediaEncoding.Encoder _logger.LogInformation("Starting {ProcessFileName} with args {ProcessArgs}", _ffprobePath, args); + var memoryStream = new MemoryStream(); + await using (memoryStream.ConfigureAwait(false)) using (var processWrapper = new ProcessWrapper(process, this)) { - await using var memoryStream = new MemoryStream(); StartProcess(processWrapper); - await process.StandardOutput.BaseStream.CopyToAsync(memoryStream, cancellationToken); + await process.StandardOutput.BaseStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); memoryStream.Seek(0, SeekOrigin.Begin); InternalMediaInfoResult result; try diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 7404c2868..1233fb110 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -11,10 +11,6 @@ <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors> - </PropertyGroup> - <ItemGroup> <Compile Include="..\SharedVersion.cs" /> </ItemGroup> @@ -35,7 +31,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index b7c2fd7b1..90bc49132 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -226,7 +226,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles await ExtractTextSubtitle(mediaSource, subtitleStream, outputCodec, outputPath, cancellationToken) .ConfigureAwait(false); - return new SubtitleInfo(outputPath, MediaProtocol.File, outputFormat, false); + return new SubtitleInfo() + { + Path = outputPath, + Protocol = MediaProtocol.File, + Format = outputFormat, + IsExternal = false + }; } var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec) @@ -240,11 +246,23 @@ namespace MediaBrowser.MediaEncoding.Subtitles await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); - return new SubtitleInfo(outputPath, MediaProtocol.File, "srt", true); + return new SubtitleInfo() + { + Path = outputPath, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; } // It's possible that the subtitleStream and mediaSource don't share the same protocol (e.g. .STRM file with local subs) - return new SubtitleInfo(subtitleStream.Path, _mediaSourceManager.GetPathProtocol(subtitleStream.Path), currentFormat, true); + return new SubtitleInfo() + { + Path = subtitleStream.Path, + Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path), + Format = currentFormat, + IsExternal = true + }; } private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value) @@ -728,23 +746,17 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - public readonly struct SubtitleInfo +#pragma warning disable CA1034 // Nested types should not be visible + // Only public for the unit tests + public readonly record struct SubtitleInfo { - public SubtitleInfo(string path, MediaProtocol protocol, string format, bool isExternal) - { - Path = path; - Protocol = protocol; - Format = format; - IsExternal = isExternal; - } - - public string Path { get; } + public string Path { get; init; } - public MediaProtocol Protocol { get; } + public MediaProtocol Protocol { get; init; } - public string Format { get; } + public string Format { get; init; } - public bool IsExternal { get; } + public bool IsExternal { get; init; } } } } diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index f4cd2f006..0ff95a2e1 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -1,128 +1,247 @@ #nullable disable -#pragma warning disable CS1591 +using MediaBrowser.Model.Entities; -namespace MediaBrowser.Model.Configuration +namespace MediaBrowser.Model.Configuration; + +/// <summary> +/// Class EncodingOptions. +/// </summary> +public class EncodingOptions { - public class EncodingOptions + /// <summary> + /// Initializes a new instance of the <see cref="EncodingOptions" /> class. + /// </summary> + public EncodingOptions() { - public EncodingOptions() - { - EnableFallbackFont = false; - DownMixAudioBoost = 2; - MaxMuxingQueueSize = 2048; - EnableThrottling = false; - ThrottleDelaySeconds = 180; - EncodingThreadCount = -1; - // This is a DRM device that is almost guaranteed to be there on every intel platform, - // plus it's the default one in ffmpeg if you don't specify anything - VaapiDevice = "/dev/dri/renderD128"; - EnableTonemapping = false; - EnableVppTonemapping = false; - TonemappingAlgorithm = "bt2390"; - TonemappingRange = "auto"; - TonemappingDesat = 0; - TonemappingThreshold = 0.8; - TonemappingPeak = 100; - TonemappingParam = 0; - VppTonemappingBrightness = 0; - VppTonemappingContrast = 1.2; - H264Crf = 23; - H265Crf = 28; - DeinterlaceDoubleRate = false; - DeinterlaceMethod = "yadif"; - EnableDecodingColorDepth10Hevc = true; - EnableDecodingColorDepth10Vp9 = true; - EnableEnhancedNvdecDecoder = false; - PreferSystemNativeHwDecoder = true; - EnableIntelLowPowerH264HwEncoder = false; - EnableIntelLowPowerHevcHwEncoder = false; - EnableHardwareEncoding = true; - AllowHevcEncoding = false; - EnableSubtitleExtraction = true; - AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" }; - HardwareDecodingCodecs = new string[] { "h264", "vc1" }; - } - - public int EncodingThreadCount { get; set; } - - public string TranscodingTempPath { get; set; } - - public string FallbackFontPath { get; set; } - - public bool EnableFallbackFont { get; set; } - - public double DownMixAudioBoost { get; set; } - - public int MaxMuxingQueueSize { get; set; } - - public bool EnableThrottling { get; set; } - - public int ThrottleDelaySeconds { get; set; } - - public string HardwareAccelerationType { get; set; } - - /// <summary> - /// Gets or sets the FFmpeg path as set by the user via the UI. - /// </summary> - public string EncoderAppPath { get; set; } - - /// <summary> - /// Gets or sets the current FFmpeg path being used by the system and displayed on the transcode page. - /// </summary> - public string EncoderAppPathDisplay { get; set; } - - public string VaapiDevice { get; set; } - - public bool EnableTonemapping { get; set; } - - public bool EnableVppTonemapping { get; set; } - - public string TonemappingAlgorithm { get; set; } - - public string TonemappingRange { get; set; } - - public double TonemappingDesat { get; set; } - - public double TonemappingThreshold { get; set; } - - public double TonemappingPeak { get; set; } - - public double TonemappingParam { get; set; } - - public double VppTonemappingBrightness { get; set; } - - public double VppTonemappingContrast { get; set; } - - public int H264Crf { get; set; } - - public int H265Crf { get; set; } - - public string EncoderPreset { get; set; } - - public bool DeinterlaceDoubleRate { get; set; } - - public string DeinterlaceMethod { get; set; } - - public bool EnableDecodingColorDepth10Hevc { get; set; } - - public bool EnableDecodingColorDepth10Vp9 { get; set; } - - public bool EnableEnhancedNvdecDecoder { get; set; } - - public bool PreferSystemNativeHwDecoder { get; set; } - - public bool EnableIntelLowPowerH264HwEncoder { get; set; } - - public bool EnableIntelLowPowerHevcHwEncoder { get; set; } - - public bool EnableHardwareEncoding { get; set; } - - public bool AllowHevcEncoding { get; set; } - - public bool EnableSubtitleExtraction { get; set; } - - public string[] HardwareDecodingCodecs { get; set; } - - public string[] AllowOnDemandMetadataBasedKeyframeExtractionForExtensions { get; set; } + EnableFallbackFont = false; + DownMixAudioBoost = 2; + DownMixStereoAlgorithm = DownMixStereoAlgorithms.None; + MaxMuxingQueueSize = 2048; + EnableThrottling = false; + ThrottleDelaySeconds = 180; + EncodingThreadCount = -1; + // This is a DRM device that is almost guaranteed to be there on every intel platform, + // plus it's the default one in ffmpeg if you don't specify anything + VaapiDevice = "/dev/dri/renderD128"; + EnableTonemapping = false; + EnableVppTonemapping = false; + TonemappingAlgorithm = "bt2390"; + TonemappingRange = "auto"; + TonemappingDesat = 0; + TonemappingThreshold = 0.8; + TonemappingPeak = 100; + TonemappingParam = 0; + VppTonemappingBrightness = 0; + VppTonemappingContrast = 1.2; + H264Crf = 23; + H265Crf = 28; + DeinterlaceDoubleRate = false; + DeinterlaceMethod = "yadif"; + EnableDecodingColorDepth10Hevc = true; + EnableDecodingColorDepth10Vp9 = true; + EnableEnhancedNvdecDecoder = false; + PreferSystemNativeHwDecoder = true; + EnableIntelLowPowerH264HwEncoder = false; + EnableIntelLowPowerHevcHwEncoder = false; + EnableHardwareEncoding = true; + AllowHevcEncoding = false; + EnableSubtitleExtraction = true; + AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" }; + HardwareDecodingCodecs = new string[] { "h264", "vc1" }; } + + /// <summary> + /// Gets or sets the thread count used for encoding. + /// </summary> + public int EncodingThreadCount { get; set; } + + /// <summary> + /// Gets or sets the temporary transcoding path. + /// </summary> + public string TranscodingTempPath { get; set; } + + /// <summary> + /// Gets or sets the path to the fallback font. + /// </summary> + public string FallbackFontPath { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to use the fallback font. + /// </summary> + public bool EnableFallbackFont { get; set; } + + /// <summary> + /// Gets or sets the audio boost applied when downmixing audio. + /// </summary> + public double DownMixAudioBoost { get; set; } + + /// <summary> + /// Gets or sets the algorithm used for downmixing audio to stereo. + /// </summary> + public DownMixStereoAlgorithms DownMixStereoAlgorithm { get; set; } + + /// <summary> + /// Gets or sets the maximum size of the muxing queue. + /// </summary> + public int MaxMuxingQueueSize { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether throttling is enabled. + /// </summary> + public bool EnableThrottling { get; set; } + + /// <summary> + /// Gets or sets the delay after which throttling happens. + /// </summary> + public int ThrottleDelaySeconds { get; set; } + + /// <summary> + /// Gets or sets the hardware acceleration type. + /// </summary> + public string HardwareAccelerationType { get; set; } + + /// <summary> + /// Gets or sets the FFmpeg path as set by the user via the UI. + /// </summary> + public string EncoderAppPath { get; set; } + + /// <summary> + /// Gets or sets the current FFmpeg path being used by the system and displayed on the transcode page. + /// </summary> + public string EncoderAppPathDisplay { get; set; } + + /// <summary> + /// Gets or sets the VA-API device. + /// </summary> + public string VaapiDevice { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether tonemapping is enabled. + /// </summary> + public bool EnableTonemapping { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether VPP tonemapping is enabled. + /// </summary> + public bool EnableVppTonemapping { get; set; } + + /// <summary> + /// Gets or sets the tone-mapping algorithm. + /// </summary> + public string TonemappingAlgorithm { get; set; } + + /// <summary> + /// Gets or sets the tone-mapping range. + /// </summary> + public string TonemappingRange { get; set; } + + /// <summary> + /// Gets or sets the tone-mapping desaturation. + /// </summary> + public double TonemappingDesat { get; set; } + + /// <summary> + /// Gets or sets the tone-mapping threshold. + /// </summary> + public double TonemappingThreshold { get; set; } + + /// <summary> + /// Gets or sets the tone-mapping peak. + /// </summary> + public double TonemappingPeak { get; set; } + + /// <summary> + /// Gets or sets the tone-mapping parameters. + /// </summary> + public double TonemappingParam { get; set; } + + /// <summary> + /// Gets or sets the VPP tone-mapping brightness. + /// </summary> + public double VppTonemappingBrightness { get; set; } + + /// <summary> + /// Gets or sets the VPP tone-mapping contrast. + /// </summary> + public double VppTonemappingContrast { get; set; } + + /// <summary> + /// Gets or sets the H264 CRF. + /// </summary> + public int H264Crf { get; set; } + + /// <summary> + /// Gets or sets the H265 CRF. + /// </summary> + public int H265Crf { get; set; } + + /// <summary> + /// Gets or sets the encoder preset. + /// </summary> + public string EncoderPreset { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the framerate is doubled when deinterlacing. + /// </summary> + public bool DeinterlaceDoubleRate { get; set; } + + /// <summary> + /// Gets or sets the deinterlace method. + /// </summary> + public string DeinterlaceMethod { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether 10bit HEVC decoding is enabled. + /// </summary> + public bool EnableDecodingColorDepth10Hevc { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether 10bit VP9 decoding is enabled. + /// </summary> + public bool EnableDecodingColorDepth10Vp9 { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the enhanced NVDEC is enabled. + /// </summary> + public bool EnableEnhancedNvdecDecoder { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the system native hardware decoder should be used. + /// </summary> + public bool PreferSystemNativeHwDecoder { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the Intel H264 low-power hardware encoder should be used. + /// </summary> + public bool EnableIntelLowPowerH264HwEncoder { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the Intel HEVC low-power hardware encoder should be used. + /// </summary> + public bool EnableIntelLowPowerHevcHwEncoder { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether hardware encoding is enabled. + /// </summary> + public bool EnableHardwareEncoding { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether HEVC encoding is enabled. + /// </summary> + public bool AllowHevcEncoding { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether subtitle extraction is enabled. + /// </summary> + public bool EnableSubtitleExtraction { get; set; } + + /// <summary> + /// Gets or sets the codecs hardware encoding is used for. + /// </summary> + public string[] HardwareDecodingCodecs { get; set; } + + /// <summary> + /// Gets or sets the file extensions on-demand metadata based keyframe extraction is enabled for. + /// </summary> + public string[] AllowOnDemandMetadataBasedKeyframeExtractionForExtensions { get; set; } } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index d3e042aba..c39162250 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -259,5 +259,11 @@ namespace MediaBrowser.Model.Configuration /// </summary> /// <value>The chapter image resolution.</value> public ImageResolution ChapterImageResolution { get; set; } = ImageResolution.MatchSource; + + /// <summary> + /// Gets or sets the limit for parallel image encoding. + /// </summary> + /// <value>The limit for parallel image encoding.</value> + public int ParallelImageEncodingLimit { get; set; } = 0; } } diff --git a/MediaBrowser.Model/Entities/DownMixStereoAlgorithms.cs b/MediaBrowser.Model/Entities/DownMixStereoAlgorithms.cs new file mode 100644 index 000000000..385cd6a34 --- /dev/null +++ b/MediaBrowser.Model/Entities/DownMixStereoAlgorithms.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.Entities; + +/// <summary> +/// An enum representing an algorithm to downmix 6ch+ to stereo. +/// Algorithms sourced from https://superuser.com/questions/852400/properly-downmix-5-1-to-stereo-using-ffmpeg/1410620#1410620. +/// </summary> +public enum DownMixStereoAlgorithms +{ + /// <summary> + /// No special algorithm. + /// </summary> + None = 0, + + /// <summary> + /// Algorithm by Dave_750. + /// </summary> + Dave750 = 1, + + /// <summary> + /// Nightmode Dialogue algorithm. + /// </summary> + NightmodeDialogue = 2 +} diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 284e89f1c..521ba0f10 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -49,7 +49,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index a82c1c8c0..9e56849c7 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -79,8 +79,9 @@ namespace MediaBrowser.Model.System /// <summary> /// Gets or sets a value indicating whether this instance can self restart. /// </summary> - /// <value><c>true</c> if this instance can self restart; otherwise, <c>false</c>.</value> - public bool CanSelfRestart { get; set; } + /// <value><c>true</c>.</value> + [Obsolete("This is always true")] + public bool CanSelfRestart { get; set; } = true; public bool CanLaunchWebBrowser { get; set; } diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index 50e704060..e7c2cd255 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -264,7 +264,8 @@ namespace MediaBrowser.Providers.Manager var fileStreamOptions = AsyncFile.WriteOptions; fileStreamOptions.Mode = FileMode.Create; fileStreamOptions.PreallocationSize = source.Length; - await using (var fs = new FileStream(path, fileStreamOptions)) + var fs = new FileStream(path, fileStreamOptions); + await using (fs.ConfigureAwait(false)) { await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index a0f48840e..d621555f1 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -502,15 +502,17 @@ namespace MediaBrowser.Providers.Manager break; } - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - await _providerManager.SaveImage( - item, - stream, - response.Content.Headers.ContentType?.MediaType, - type, - null, - cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + await _providerManager.SaveImage( + item, + stream, + response.Content.Headers.ContentType?.MediaType, + type, + null, + cancellationToken).ConfigureAwait(false); + } result.UpdateType |= ItemUpdateType.ImageUpdate; return true; @@ -626,14 +628,18 @@ namespace MediaBrowser.Providers.Manager } } - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await _providerManager.SaveImage( - item, - stream, - response.Content.Headers.ContentType?.MediaType, - imageType, - null, - cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + await _providerManager.SaveImage( + item, + stream, + response.Content.Headers.ContentType?.MediaType, + imageType, + null, + cancellationToken).ConfigureAwait(false); + } + result.UpdateType |= ItemUpdateType.ImageUpdate; } catch (HttpRequestException) diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 914da33a9..0ce696edc 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -182,14 +182,17 @@ namespace MediaBrowser.Providers.Manager contentType = MimeTypes.GetMimeType(url); } - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await SaveImage( - item, - stream, - contentType, - type, - imageIndex, - cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + await SaveImage( + item, + stream, + contentType, + type, + imageIndex, + cancellationToken).ConfigureAwait(false); + } } /// <inheritdoc/> diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index dbacc2a82..13de86a92 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -24,7 +24,7 @@ <PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> <PackageReference Include="PlaylistsNET" Version="1.3.1" /> <PackageReference Include="TagLibSharp" Version="2.3.0" /> - <PackageReference Include="TMDbLib" Version="1.9.2" /> + <PackageReference Include="TMDbLib" Version="2.0.0" /> </ItemGroup> <PropertyGroup> @@ -34,13 +34,9 @@ <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors> - </PropertyGroup> - <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs index fed23df15..f58f5f7a3 100644 --- a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs @@ -140,7 +140,7 @@ namespace MediaBrowser.Providers.MediaInfo if (attachmentStream is not null) { - return await ExtractAttachment(item, attachmentStream, mediaSource, cancellationToken); + return await ExtractAttachment(item, attachmentStream, mediaSource, cancellationToken).ConfigureAwait(false); } // Fall back to EmbeddedImage streams diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 751135a2c..81434b862 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -557,7 +557,7 @@ namespace MediaBrowser.Providers.MediaInfo CancellationToken cancellationToken) { var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1); - var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken); + var externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, false, cancellationToken).ConfigureAwait(false); var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh; @@ -611,7 +611,7 @@ namespace MediaBrowser.Providers.MediaInfo // Rescan if (downloadedLanguages.Count > 0) { - externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken); + externalSubtitleStreams = await _subtitleResolver.GetExternalStreamsAsync(video, startIndex, options.DirectoryService, true, cancellationToken).ConfigureAwait(false); } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs index 7fb438d8a..7f73afc53 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumImageProvider.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; @@ -42,11 +43,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { - return new List<ImageType> - { - ImageType.Primary, - ImageType.Disc - }; + yield return ImageType.Primary; + yield return ImageType.Disc; } /// <inheritdoc /> @@ -60,16 +58,19 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbAlbumProvider.GetAlbumInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = AsyncFile.OpenRead(path); - var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - - if (obj is not null && obj.album is not null && obj.album.Count > 0) + FileStream jsonStream = AsyncFile.OpenRead(path); + await using (jsonStream.ConfigureAwait(false)) { - return GetImages(obj.album[0]); + var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + + if (obj is not null && obj.album is not null && obj.album.Count > 0) + { + return GetImages(obj.album[0]); + } } } - return new List<RemoteImageInfo>(); + return Enumerable.Empty<RemoteImageInfo>(); } private IEnumerable<RemoteImageInfo> GetImages(AudioDbAlbumProvider.Album item) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index b92f1f59f..55e2474a5 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -68,14 +68,17 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetAlbumInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = AsyncFile.OpenRead(path); - var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - - if (obj is not null && obj.album is not null && obj.album.Count > 0) + FileStream jsonStream = AsyncFile.OpenRead(path); + await using (jsonStream.ConfigureAwait(false)) { - result.Item = new MusicAlbum(); - result.HasMetadata = true; - ProcessResult(result.Item, obj.album[0], info.MetadataLanguage); + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + + if (obj is not null && obj.album is not null && obj.album.Count > 0) + { + result.Item = new MusicAlbum(); + result.HasMetadata = true; + ProcessResult(result.Item, obj.album[0], info.MetadataLanguage); + } } } @@ -173,13 +176,18 @@ namespace MediaBrowser.Providers.Plugins.AudioDb Directory.CreateDirectory(Path.GetDirectoryName(path)); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - var fileStreamOptions = AsyncFile.WriteOptions; - fileStreamOptions.Mode = FileMode.Create; - fileStreamOptions.PreallocationSize = stream.Length; - await using var xmlFileStream = new FileStream(path, fileStreamOptions); - await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + var fileStreamOptions = AsyncFile.WriteOptions; + fileStreamOptions.Mode = FileMode.Create; + fileStreamOptions.PreallocationSize = stream.Length; + var xmlFileStream = new FileStream(path, fileStreamOptions); + await using (xmlFileStream.ConfigureAwait(false)) + { + await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); + } + } } private static string GetAlbumDataPath(IApplicationPaths appPaths, string musicBrainzReleaseGroupId) diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index 6d67ad634..b1a285a96 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -62,12 +62,15 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = AsyncFile.OpenRead(path); - var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + FileStream jsonStream = AsyncFile.OpenRead(path); + await using (jsonStream.ConfigureAwait(false)) { - return GetImages(obj.artists[0]); + var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + + if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + { + return GetImages(obj.artists[0]); + } } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index 1565a8c51..f3385b3a9 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -67,14 +67,17 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetArtistInfoPath(_config.ApplicationPaths, id); - await using FileStream jsonStream = AsyncFile.OpenRead(path); - var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + FileStream jsonStream = AsyncFile.OpenRead(path); + await using (jsonStream.ConfigureAwait(false)) { - result.Item = new MusicArtist(); - result.HasMetadata = true; - ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage); + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + + if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + { + result.Item = new MusicArtist(); + result.HasMetadata = true; + ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage); + } } } @@ -151,16 +154,21 @@ namespace MediaBrowser.Providers.Plugins.AudioDb using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - var fileStreamOptions = AsyncFile.WriteOptions; - fileStreamOptions.Mode = FileMode.Create; - fileStreamOptions.PreallocationSize = stream.Length; - await using var xmlFileStream = new FileStream(path, fileStreamOptions); - await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var fileStreamOptions = AsyncFile.WriteOptions; + fileStreamOptions.Mode = FileMode.Create; + fileStreamOptions.PreallocationSize = stream.Length; + var xmlFileStream = new FileStream(path, fileStreamOptions); + await using (xmlFileStream.ConfigureAwait(false)) + { + await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); + } + } } /// <summary> diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 3ef94ca93..e4bb4eaea 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -137,29 +137,31 @@ namespace MediaBrowser.Providers.Plugins.Omdb var url = OmdbProvider.GetOmdbUrl(urlQuery.ToString()); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - if (isSearch) + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { - var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (searchResultList?.Search is not null) + if (isSearch) { - var resultCount = searchResultList.Search.Count; - var result = new RemoteSearchResult[resultCount]; - for (var i = 0; i < resultCount; i++) + var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (searchResultList?.Search is not null) { - result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd); + var resultCount = searchResultList.Search.Count; + var result = new RemoteSearchResult[resultCount]; + for (var i = 0; i < resultCount; i++) + { + result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd); + } + + return result; } - - return result; } - } - else - { - var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase)) + else { - return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) }; + var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase)) + { + return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) }; + } } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 6713a34e6..497437bd8 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -234,15 +234,21 @@ namespace MediaBrowser.Providers.Plugins.Omdb internal async Task<RootObject> GetRootObject(string imdbId, CancellationToken cancellationToken) { var path = await EnsureItemInfo(imdbId, cancellationToken).ConfigureAwait(false); - await using var stream = AsyncFile.OpenRead(path); - return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var stream = AsyncFile.OpenRead(path); + await using (stream.ConfigureAwait(false)) + { + return await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + } } internal async Task<SeasonRootObject> GetSeasonRootObject(string imdbId, int seasonId, CancellationToken cancellationToken) { var path = await EnsureSeasonInfo(imdbId, seasonId, cancellationToken).ConfigureAwait(false); - await using var stream = AsyncFile.OpenRead(path); - return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var stream = AsyncFile.OpenRead(path); + await using (stream.ConfigureAwait(false)) + { + return await JsonSerializer.DeserializeAsync<SeasonRootObject>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + } } /// <summary>Gets OMDB URL.</summary> @@ -317,8 +323,11 @@ namespace MediaBrowser.Providers.Plugins.Omdb imdbParam)); var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<RootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false); - await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); + FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await using (jsonFileStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); + } return path; } @@ -357,8 +366,11 @@ namespace MediaBrowser.Providers.Plugins.Omdb seasonId)); var rootObject = await _httpClientFactory.CreateClient(NamedClient.Default).GetFromJsonAsync<SeasonRootObject>(url, _jsonOptions, cancellationToken).ConfigureAwait(false); - await using FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); + FileStream jsonFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await using (jsonFileStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); + } return path; } diff --git a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs index 4ff9e0247..0fb9d30a6 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs @@ -138,9 +138,15 @@ namespace MediaBrowser.Providers.Plugins.StudioImages var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); Directory.CreateDirectory(Path.GetDirectoryName(file)); - await using var response = await httpClient.GetStreamAsync(url, cancellationToken).ConfigureAwait(false); - await using var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await response.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + var response = await httpClient.GetStreamAsync(url, cancellationToken).ConfigureAwait(false); + await using (response.ConfigureAwait(false)) + { + var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await using (fileStream.ConfigureAwait(false)) + { + await response.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + } } return file; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 20898d213..eee3658de 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -81,8 +81,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var backdrops = collection.Images.Backdrops; var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count); - _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); - _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages); + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index 01b8bca39..02601d3f5 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -100,9 +100,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var logos = movie.Images.Logos; var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count); - _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); - _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages); - _tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language, remoteImages); + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index aa46d8f25..bc959ee2b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -69,12 +69,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People return Enumerable.Empty<RemoteImageInfo>(); } - var profiles = personResult.Images.Profiles; - var remoteImages = new List<RemoteImageInfo>(profiles.Count); - - _tmdbClientManager.ConvertProfilesToRemoteImageInfo(profiles, language, remoteImages); - - return remoteImages; + return _tmdbClientManager.ConvertProfilesToRemoteImageInfo(personResult.Images.Profiles, language); } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 127d41cc7..5259faf76 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -89,11 +89,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return Enumerable.Empty<RemoteImageInfo>(); } - var remoteImages = new List<RemoteImageInfo>(stills.Count); - - _tmdbClientManager.ConvertStillsToRemoteImageInfo(stills, language, remoteImages); - - return remoteImages; + return _tmdbClientManager.ConvertStillsToRemoteImageInfo(stills, language); } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index fda00537d..b8d1460db 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -80,11 +80,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return Enumerable.Empty<RemoteImageInfo>(); } - var remoteImages = new List<RemoteImageInfo>(posters.Count); - - _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); - - return remoteImages; + return _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language); } /// <inheritdoc /> diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 9062f1b85..79cb6e86d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -83,9 +83,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var logos = series.Images.Logos; var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count); - _tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language, remoteImages); - _tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language, remoteImages); - _tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language, remoteImages); + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index b56c0d748..c7441bf35 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -531,55 +531,45 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="images">The input images.</param> /// <param name="requestLanguage">The requested language.</param> - /// <param name="results">The collection to add the remote images into.</param> - public void ConvertPostersToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) - { - ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.PosterSize, ImageType.Primary, requestLanguage, results); - } + /// <returns>The remote images.</returns> + public IEnumerable<RemoteImageInfo> ConvertPostersToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage) + => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.PosterSize, ImageType.Primary, requestLanguage); /// <summary> /// Converts backdrop <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s. /// </summary> /// <param name="images">The input images.</param> /// <param name="requestLanguage">The requested language.</param> - /// <param name="results">The collection to add the remote images into.</param> - public void ConvertBackdropsToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) - { - ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.BackdropSize, ImageType.Backdrop, requestLanguage, results); - } + /// <returns>The remote images.</returns> + public IEnumerable<RemoteImageInfo> ConvertBackdropsToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage) + => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.BackdropSize, ImageType.Backdrop, requestLanguage); /// <summary> /// Converts logo <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s. /// </summary> /// <param name="images">The input images.</param> /// <param name="requestLanguage">The requested language.</param> - /// <param name="results">The collection to add the remote images into.</param> - public void ConvertLogosToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) - { - ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.LogoSize, ImageType.Logo, requestLanguage, results); - } + /// <returns>The remote images.</returns> + public IEnumerable<RemoteImageInfo> ConvertLogosToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage) + => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.LogoSize, ImageType.Logo, requestLanguage); /// <summary> /// Converts profile <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s. /// </summary> /// <param name="images">The input images.</param> /// <param name="requestLanguage">The requested language.</param> - /// <param name="results">The collection to add the remote images into.</param> - public void ConvertProfilesToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) - { - ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.ProfileSize, ImageType.Primary, requestLanguage, results); - } + /// <returns>The remote images.</returns> + public IEnumerable<RemoteImageInfo> ConvertProfilesToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage) + => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.ProfileSize, ImageType.Primary, requestLanguage); /// <summary> /// Converts still <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s. /// </summary> /// <param name="images">The input images.</param> /// <param name="requestLanguage">The requested language.</param> - /// <param name="results">The collection to add the remote images into.</param> - public void ConvertStillsToRemoteImageInfo(List<ImageData> images, string requestLanguage, List<RemoteImageInfo> results) - { - ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.StillSize, ImageType.Primary, requestLanguage, results); - } + /// <returns>The remote images.</returns> + public IEnumerable<RemoteImageInfo> ConvertStillsToRemoteImageInfo(IReadOnlyList<ImageData> images, string requestLanguage) + => ConvertToRemoteImageInfo(images, Plugin.Instance.Configuration.StillSize, ImageType.Primary, requestLanguage); /// <summary> /// Converts <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s. @@ -588,8 +578,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="size">The size of the image to fetch.</param> /// <param name="type">The type of the image.</param> /// <param name="requestLanguage">The requested language.</param> - /// <param name="results">The collection to add the remote images into.</param> - private void ConvertToRemoteImageInfo(List<ImageData> images, string size, ImageType type, string requestLanguage, List<RemoteImageInfo> results) + /// <returns>The remote images.</returns> + private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string size, ImageType type, string requestLanguage) { // sizes provided are for original resolution, don't store them when downloading scaled images var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase); @@ -598,7 +588,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb { var image = images[i]; - results.Add(new RemoteImageInfo + yield return new RemoteImageInfo { Url = GetUrl(size, image.FilePath), CommunityRating = image.VoteAverage, @@ -609,7 +599,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb ProviderName = TmdbUtils.ProviderName, Type = type, RatingType = RatingType.Score - }); + }; } } diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index 1aeffb65f..b1a26cfba 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -188,10 +188,16 @@ namespace MediaBrowser.Providers.Subtitles { var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; - await using var stream = response.Stream; - await using var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream).ConfigureAwait(false); - memoryStream.Position = 0; + var memoryStream = new MemoryStream(); + await using (memoryStream.ConfigureAwait(false)) + { + var stream = response.Stream; + await using (stream.ConfigureAwait(false)) + { + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; + } + } var savePaths = new List<string>(); var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index 6e82d96d1..c25932a5a 100644 --- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj +++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj @@ -22,7 +22,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/debian/bin/restart.sh b/debian/bin/restart.sh deleted file mode 100755 index 4847b918b..000000000 --- a/debian/bin/restart.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash - -# restart.sh - Jellyfin server restart script -# Part of the Jellyfin project (https://github.com/jellyfin) -# -# This script restarts the Jellyfin daemon on Linux when using -# the Restart button on the admin dashboard. It supports the -# systemctl, service, and traditional /etc/init.d (sysv) restart -# methods, chosen automatically by which one is found first (in -# that order). -# -# This script is used by the Debian/Ubuntu/Fedora/CentOS packages. - -# This is the Right Way(tm) to check if we are booted with -# systemd, according to sd_booted(3) -if [ -d /run/systemd/system ]; then - cmd=systemctl -else - # Everything else is really hard to figure out, so we just use - # service(8) if it's available - that works with most init - # systems/distributions I know of, including FreeBSD - if type service >/dev/null 2>&1; then - cmd=service - else - # If even service(8) isn't available, we just try /etc/init.d - # and hope for the best - if [ -d /etc/init.d ]; then - cmd=sysv - else - echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 - echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 - exit 1 - fi - fi -fi - -if type sudo >/dev/null 2>&1; then - sudo_command=sudo -else - sudo_command= -fi - -echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." -case $cmd in - 'systemctl') - # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too - $sudo_command systemd-run systemctl restart jellyfin - ;; - 'service') - echo "sleep 0.5; $sudo_command service jellyfin start" | at now - ;; - 'sysv') - echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now - ;; -esac -exit 0 diff --git a/debian/conf/jellyfin b/debian/conf/jellyfin index 2f0630a9c..912996755 100644 --- a/debian/conf/jellyfin +++ b/debian/conf/jellyfin @@ -21,9 +21,6 @@ JELLYFIN_CACHE_DIR="/var/cache/jellyfin" # web client path, installed by the jellyfin-web package JELLYFIN_WEB_OPT="--webdir=/usr/share/jellyfin/web" -# Restart script for in-app server control -JELLYFIN_RESTART_OPT="--restartpath=/usr/lib/jellyfin/restart.sh" - # ffmpeg binary paths, overriding the system values JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/lib/jellyfin-ffmpeg/ffmpeg" @@ -50,4 +47,4 @@ JELLYFIN_ADDITIONAL_OPTS="" # Application username JELLYFIN_USER="jellyfin" # Full application command -JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS" +JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS" diff --git a/debian/install b/debian/install index 994322d14..593b13a7b 100644 --- a/debian/install +++ b/debian/install @@ -3,4 +3,3 @@ debian/conf/jellyfin etc/default/ debian/conf/logging.json etc/jellyfin/ debian/conf/jellyfin.service.conf etc/systemd/system/jellyfin.service.d/ debian/conf/jellyfin-sudoers etc/sudoers.d/ -debian/bin/restart.sh usr/lib/jellyfin/ diff --git a/debian/jellyfin.service b/debian/jellyfin.service index 1150924a0..2cc49f7c4 100644 --- a/debian/jellyfin.service +++ b/debian/jellyfin.service @@ -8,7 +8,7 @@ EnvironmentFile = /etc/default/jellyfin User = jellyfin Group = jellyfin WorkingDirectory = /var/lib/jellyfin -ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS +ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS Restart = on-failure TimeoutSec = 15 SuccessExitStatus=0 143 diff --git a/debian/postinst b/debian/postinst index a15442c76..947959aa7 100644 --- a/debian/postinst +++ b/debian/postinst @@ -59,8 +59,6 @@ case "$1" in chgrp adm $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA chmod 0750 $PROGRAMDATA $CONFIGDATA $LOGDATA $CACHEDATA - chmod +x /usr/lib/jellyfin/restart.sh > /dev/null 2>&1 || true - # Install jellyfin symlink into /usr/bin ln -sf /usr/lib/jellyfin/bin/jellyfin /usr/bin/jellyfin diff --git a/fedora/jellyfin.env b/fedora/jellyfin.env index 1ccd8196f..1f79fac4f 100644 --- a/fedora/jellyfin.env +++ b/fedora/jellyfin.env @@ -23,9 +23,6 @@ JELLYFIN_CACHE_DIR="/var/cache/jellyfin" # web client path, installed by the jellyfin-web package # JELLYFIN_WEB_OPT="--webdir=/usr/share/jellyfin-web" -# In-App service control -JELLYFIN_RESTART_OPT="--restartpath=/usr/libexec/jellyfin/restart.sh" - # [OPTIONAL] ffmpeg binary paths, overriding the UI-configured values #JELLYFIN_FFMPEG_OPT="--ffmpeg=/usr/bin/ffmpeg" diff --git a/fedora/jellyfin.service b/fedora/jellyfin.service index 2fb9f30e0..1b3f8032c 100644 --- a/fedora/jellyfin.service +++ b/fedora/jellyfin.service @@ -8,7 +8,7 @@ EnvironmentFile = /etc/sysconfig/jellyfin User = jellyfin Group = jellyfin WorkingDirectory = /var/lib/jellyfin -ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_RESTART_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS +ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS Restart = on-failure TimeoutSec = 15 SuccessExitStatus=0 143 diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index 08de71537..245687789 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -17,10 +17,9 @@ Source0: jellyfin-server-%{version}.tar.gz Source11: jellyfin.service Source12: jellyfin.env Source13: jellyfin.sudoers -Source14: restart.sh -Source15: jellyfin.override.conf -Source16: jellyfin-firewalld.xml -Source17: jellyfin-server-lowports.conf +Source14: jellyfin.override.conf +Source15: jellyfin-firewalld.xml +Source16: jellyfin-server-lowports.conf %{?systemd_requires} BuildRequires: systemd @@ -76,16 +75,15 @@ dotnet publish --configuration Release --self-contained --runtime %{dotnet_runti %{__mkdir} -p %{buildroot}%{_libdir}/jellyfin %{buildroot}%{_bindir} %{__cp} -r Jellyfin.Server/bin/Release/net7.0/%{dotnet_runtime}/publish/* %{buildroot}%{_libdir}/jellyfin ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin -%{__install} -D %{SOURCE14} %{buildroot}%{_libexecdir}/jellyfin/restart.sh # Jellyfin config %{__install} -D Jellyfin.Server/Resources/Configuration/logging.json %{buildroot}%{_sysconfdir}/jellyfin/logging.json %{__install} -D %{SOURCE12} %{buildroot}%{_sysconfdir}/sysconfig/jellyfin # system config -%{__install} -D %{SOURCE16} %{buildroot}%{_prefix}/lib/firewalld/services/jellyfin.xml +%{__install} -D %{SOURCE15} %{buildroot}%{_prefix}/lib/firewalld/services/jellyfin.xml %{__install} -D %{SOURCE13} %{buildroot}%{_sysconfdir}/sudoers.d/jellyfin-sudoers -%{__install} -D %{SOURCE15} %{buildroot}%{_sysconfdir}/systemd/system/jellyfin.service.d/override.conf +%{__install} -D %{SOURCE14} %{buildroot}%{_sysconfdir}/systemd/system/jellyfin.service.d/override.conf %{__install} -D %{SOURCE11} %{buildroot}%{_unitdir}/jellyfin.service # empty directories @@ -95,7 +93,7 @@ ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin %{__mkdir} -p %{buildroot}%{_var}/log/jellyfin # jellyfin-server-lowports subpackage -%{__install} -D -m 0644 %{SOURCE17} %{buildroot}%{_unitdir}/jellyfin.service.d/jellyfin-server-lowports.conf +%{__install} -D -m 0644 %{SOURCE16} %{buildroot}%{_unitdir}/jellyfin.service.d/jellyfin-server-lowports.conf %files @@ -110,7 +108,6 @@ ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin %attr(755,root,root) %{_libdir}/jellyfin/createdump %attr(755,root,root) %{_libdir}/jellyfin/jellyfin %{_libdir}/jellyfin/* -%attr(755,root,root) %{_libexecdir}/jellyfin/restart.sh # Jellyfin config %config(noreplace) %attr(644,jellyfin,jellyfin) %{_sysconfdir}/jellyfin/logging.json diff --git a/fedora/restart.sh b/fedora/restart.sh deleted file mode 100755 index 4847b918b..000000000 --- a/fedora/restart.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash - -# restart.sh - Jellyfin server restart script -# Part of the Jellyfin project (https://github.com/jellyfin) -# -# This script restarts the Jellyfin daemon on Linux when using -# the Restart button on the admin dashboard. It supports the -# systemctl, service, and traditional /etc/init.d (sysv) restart -# methods, chosen automatically by which one is found first (in -# that order). -# -# This script is used by the Debian/Ubuntu/Fedora/CentOS packages. - -# This is the Right Way(tm) to check if we are booted with -# systemd, according to sd_booted(3) -if [ -d /run/systemd/system ]; then - cmd=systemctl -else - # Everything else is really hard to figure out, so we just use - # service(8) if it's available - that works with most init - # systems/distributions I know of, including FreeBSD - if type service >/dev/null 2>&1; then - cmd=service - else - # If even service(8) isn't available, we just try /etc/init.d - # and hope for the best - if [ -d /etc/init.d ]; then - cmd=sysv - else - echo "Unable to detect a way to restart Jellyfin; bailing out" 1>&2 - echo "Please report this bug to https://github.com/jellyfin/jellyfin/issues" 1>&2 - exit 1 - fi - fi -fi - -if type sudo >/dev/null 2>&1; then - sudo_command=sudo -else - sudo_command= -fi - -echo "Detected service control platform '$cmd'; using it to restart Jellyfin..." -case $cmd in - 'systemctl') - # Without systemd-run here, `jellyfin.service`'s shutdown terminates this process too - $sudo_command systemd-run systemctl restart jellyfin - ;; - 'service') - echo "sleep 0.5; $sudo_command service jellyfin start" | at now - ;; - 'sysv') - echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now - ;; -esac -exit 0 diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj index 9c2449da4..51df09a21 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -19,7 +19,7 @@ <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> <PackageReference Include="Moq" Version="4.18.4" /> - <PackageReference Include="SharpFuzz" Version="2.0.0" /> + <PackageReference Include="SharpFuzz" Version="2.0.1" /> </ItemGroup> </Project> diff --git a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj index 5e7d14b11..226ab60da 100644 --- a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj +++ b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj @@ -16,7 +16,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="SharpFuzz" Version="2.0.0" /> + <PackageReference Include="SharpFuzz" Version="2.0.1" /> </ItemGroup> </Project> diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index dac3d0a61..a62ebf78c 100644 --- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -12,7 +12,7 @@ </PropertyGroup> <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> + <Compile Include="..\..\SharedVersion.cs" /> </ItemGroup> <ItemGroup> @@ -24,14 +24,14 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> - <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> - <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> + <ProjectReference Include="..\..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> + <ProjectReference Include="..\..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> + <ProjectReference Include="..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> </ItemGroup> <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs b/src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs new file mode 100644 index 000000000..e2e90be47 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/PercentPlayedDrawer.cs @@ -0,0 +1,35 @@ +using System; +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Static helper class used to draw percentage-played indicators on images. +/// </summary> +public static class PercentPlayedDrawer +{ + private const int IndicatorHeight = 8; + + /// <summary> + /// Draw a percentage played indicator on a canvas. + /// </summary> + /// <param name="canvas">The canvas to draw the indicator on.</param> + /// <param name="imageSize">The size of the image being drawn on.</param> + /// <param name="percent">The percentage played to display with the indicator.</param> + public static void Process(SKCanvas canvas, ImageDimensions imageSize, double percent) + { + using var paint = new SKPaint(); + var endX = imageSize.Width - 1; + var endY = imageSize.Height - 1; + + paint.Color = SKColor.Parse("#99000000"); + paint.Style = SKPaintStyle.Fill; + canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, endX, endY), paint); + + double foregroundWidth = (endX * percent) / 100; + + paint.Color = SKColor.Parse("#FF00A4DC"); + canvas.DrawRect(SKRect.Create(0, (float)endY - IndicatorHeight, Convert.ToInt32(foregroundWidth), endY), paint); + } +} diff --git a/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs b/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs new file mode 100644 index 000000000..5bb42fb99 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/PlayedIndicatorDrawer.cs @@ -0,0 +1,47 @@ +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Static helper class for drawing 'played' indicators. +/// </summary> +public static class PlayedIndicatorDrawer +{ + private const int OffsetFromTopRightCorner = 38; + + /// <summary> + /// Draw a 'played' indicator in the top right corner of a canvas. + /// </summary> + /// <param name="canvas">The canvas to draw the indicator on.</param> + /// <param name="imageSize"> + /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the + /// indicator. + /// </param> + public static void DrawPlayedIndicator(SKCanvas canvas, ImageDimensions imageSize) + { + var x = imageSize.Width - OffsetFromTopRightCorner; + + using var paint = new SKPaint + { + Color = SKColor.Parse("#CC00A4DC"), + Style = SKPaintStyle.Fill + }; + + canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); + + paint.Color = new SKColor(255, 255, 255, 255); + paint.TextSize = 30; + paint.IsAntialias = true; + + // or: + // var emojiChar = 0x1F680; + const string Text = "✔️"; + var emojiChar = StringUtilities.GetUnicodeCharacterCode(Text, SKTextEncoding.Utf32); + + // ask the font manager for a font with that character + paint.Typeface = SKFontManager.Default.MatchCharacter(emojiChar); + + canvas.DrawText(Text, (float)x - 12, OffsetFromTopRightCorner + 12, paint); + } +} diff --git a/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs b/src/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs index e7db09449..e7db09449 100644 --- a/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs +++ b/src/Jellyfin.Drawing.Skia/Properties/AssemblyInfo.cs diff --git a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs new file mode 100644 index 000000000..581fa000d --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Represents errors that occur during interaction with Skia codecs. +/// </summary> +public class SkiaCodecException : SkiaException +{ + /// <summary> + /// Initializes a new instance of the <see cref="SkiaCodecException" /> class. + /// </summary> + /// <param name="result">The non-successful codec result returned by Skia.</param> + public SkiaCodecException(SKCodecResult result) + { + CodecResult = result; + } + + /// <summary> + /// Initializes a new instance of the <see cref="SkiaCodecException" /> class + /// with a specified error message. + /// </summary> + /// <param name="result">The non-successful codec result returned by Skia.</param> + /// <param name="message">The message that describes the error.</param> + public SkiaCodecException(SKCodecResult result, string message) + : base(message) + { + CodecResult = result; + } + + /// <summary> + /// Gets the non-successful codec result returned by Skia. + /// </summary> + public SKCodecResult CodecResult { get; } + + /// <inheritdoc /> + public override string ToString() + => string.Format( + CultureInfo.InvariantCulture, + "Non-success codec result: {0}\n{1}", + CodecResult, + base.ToString()); +} diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs new file mode 100644 index 000000000..ddb8a98d4 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -0,0 +1,544 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using BlurHashSharp.SkiaSharp; +using Jellyfin.Extensions; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Model.Drawing; +using Microsoft.Extensions.Logging; +using SkiaSharp; +using SKSvg = SkiaSharp.Extended.Svg.SKSvg; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Image encoder that uses <see cref="SkiaSharp"/> to manipulate images. +/// </summary> +public class SkiaEncoder : IImageEncoder +{ + private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" }; + + private readonly ILogger<SkiaEncoder> _logger; + private readonly IApplicationPaths _appPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="SkiaEncoder"/> class. + /// </summary> + /// <param name="logger">The application logger.</param> + /// <param name="appPaths">The application paths.</param> + public SkiaEncoder(ILogger<SkiaEncoder> logger, IApplicationPaths appPaths) + { + _logger = logger; + _appPaths = appPaths; + } + + /// <inheritdoc/> + public string Name => "Skia"; + + /// <inheritdoc/> + public bool SupportsImageCollageCreation => true; + + /// <inheritdoc/> + public bool SupportsImageEncoding => true; + + /// <inheritdoc/> + public IReadOnlyCollection<string> SupportedInputFormats => + new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "jpeg", + "jpg", + "png", + "dng", + "webp", + "gif", + "bmp", + "ico", + "astc", + "ktx", + "pkm", + "wbmp", + // TODO: check if these are supported on multiple platforms + // https://github.com/google/skia/blob/master/infra/bots/recipes/test.py#L454 + // working on windows at least + "cr2", + "nef", + "arw" + }; + + /// <inheritdoc/> + public IReadOnlyCollection<ImageFormat> SupportedOutputFormats + => new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png }; + + /// <summary> + /// Check if the native lib is available. + /// </summary> + /// <returns>True if the native lib is available, otherwise false.</returns> + public static bool IsNativeLibAvailable() + { + try + { + // test an operation that requires the native library + SKPMColor.PreMultiply(SKColors.Black); + return true; + } + catch (Exception) + { + return false; + } + } + + /// <summary> + /// Convert a <see cref="ImageFormat"/> to a <see cref="SKEncodedImageFormat"/>. + /// </summary> + /// <param name="selectedFormat">The format to convert.</param> + /// <returns>The converted format.</returns> + public static SKEncodedImageFormat GetImageFormat(ImageFormat selectedFormat) + { + return selectedFormat switch + { + ImageFormat.Bmp => SKEncodedImageFormat.Bmp, + ImageFormat.Jpg => SKEncodedImageFormat.Jpeg, + ImageFormat.Gif => SKEncodedImageFormat.Gif, + ImageFormat.Webp => SKEncodedImageFormat.Webp, + _ => SKEncodedImageFormat.Png + }; + } + + /// <inheritdoc /> + /// <exception cref="FileNotFoundException">The path is not valid.</exception> + public ImageDimensions GetImageSize(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException("File not found", path); + } + + var extension = Path.GetExtension(path.AsSpan()); + if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase)) + { + var svg = new SKSvg(); + svg.Load(path); + return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height)); + } + + using var codec = SKCodec.Create(path, out SKCodecResult result); + switch (result) + { + case SKCodecResult.Success: + var info = codec.Info; + return new ImageDimensions(info.Width, info.Height); + case SKCodecResult.Unimplemented: + _logger.LogDebug("Image format not supported: {FilePath}", path); + return new ImageDimensions(0, 0); + default: + _logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result); + return new ImageDimensions(0, 0); + } + } + + /// <inheritdoc /> + /// <exception cref="ArgumentNullException">The path is null.</exception> + /// <exception cref="FileNotFoundException">The path is not valid.</exception> + /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception> + public string GetImageBlurHash(int xComp, int yComp, string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + var extension = Path.GetExtension(path.AsSpan()).TrimStart('.'); + if (!SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Unable to compute blur hash due to unsupported format: {ImagePath}", path); + return string.Empty; + } + + // Any larger than 128x128 is too slow and there's no visually discernible difference + return BlurHashEncoder.Encode(xComp, yComp, path, 128, 128); + } + + private bool RequiresSpecialCharacterHack(string path) + { + for (int i = 0; i < path.Length; i++) + { + if (char.GetUnicodeCategory(path[i]) == UnicodeCategory.OtherLetter) + { + return true; + } + } + + return path.HasDiacritics(); + } + + private string NormalizePath(string path) + { + if (!RequiresSpecialCharacterHack(path)) + { + return path; + } + + var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path)); + var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); + Directory.CreateDirectory(directory); + File.Copy(path, tempPath, true); + + return tempPath; + } + + private static SKEncodedOrigin GetSKEncodedOrigin(ImageOrientation? orientation) + { + if (!orientation.HasValue) + { + return SKEncodedOrigin.TopLeft; + } + + return orientation.Value switch + { + ImageOrientation.TopRight => SKEncodedOrigin.TopRight, + ImageOrientation.RightTop => SKEncodedOrigin.RightTop, + ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom, + ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop, + ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom, + ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight, + ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft, + _ => SKEncodedOrigin.TopLeft + }; + } + + /// <summary> + /// Decode an image. + /// </summary> + /// <param name="path">The filepath of the image to decode.</param> + /// <param name="forceCleanBitmap">Whether to force clean the bitmap.</param> + /// <param name="orientation">The orientation of the image.</param> + /// <param name="origin">The detected origin of the image.</param> + /// <returns>The resulting bitmap of the image.</returns> + internal SKBitmap? Decode(string path, bool forceCleanBitmap, ImageOrientation? orientation, out SKEncodedOrigin origin) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException("File not found", path); + } + + var requiresTransparencyHack = _transparentImageTypes.Contains(Path.GetExtension(path)); + + if (requiresTransparencyHack || forceCleanBitmap) + { + using SKCodec codec = SKCodec.Create(NormalizePath(path), out SKCodecResult res); + if (res != SKCodecResult.Success) + { + origin = GetSKEncodedOrigin(orientation); + return null; + } + + // create the bitmap + var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack); + + // decode + _ = codec.GetPixels(bitmap.Info, bitmap.GetPixels()); + + origin = codec.EncodedOrigin; + + return bitmap; + } + + var resultBitmap = SKBitmap.Decode(NormalizePath(path)); + + if (resultBitmap is null) + { + return Decode(path, true, orientation, out origin); + } + + // If we have to resize these they often end up distorted + if (resultBitmap.ColorType == SKColorType.Gray8) + { + using (resultBitmap) + { + return Decode(path, true, orientation, out origin); + } + } + + origin = SKEncodedOrigin.TopLeft; + return resultBitmap; + } + + private SKBitmap? GetBitmap(string path, bool autoOrient, ImageOrientation? orientation) + { + if (autoOrient) + { + var bitmap = Decode(path, true, orientation, out var origin); + + if (bitmap is not null && origin != SKEncodedOrigin.TopLeft) + { + using (bitmap) + { + return OrientImage(bitmap, origin); + } + } + + return bitmap; + } + + return Decode(path, false, orientation, out _); + } + + private SKBitmap OrientImage(SKBitmap bitmap, SKEncodedOrigin origin) + { + var needsFlip = origin == SKEncodedOrigin.LeftBottom + || origin == SKEncodedOrigin.LeftTop + || origin == SKEncodedOrigin.RightBottom + || origin == SKEncodedOrigin.RightTop; + var rotated = needsFlip + ? new SKBitmap(bitmap.Height, bitmap.Width) + : new SKBitmap(bitmap.Width, bitmap.Height); + using var surface = new SKCanvas(rotated); + var midX = (float)rotated.Width / 2; + var midY = (float)rotated.Height / 2; + + switch (origin) + { + case SKEncodedOrigin.TopRight: + surface.Scale(-1, 1, midX, midY); + break; + case SKEncodedOrigin.BottomRight: + surface.RotateDegrees(180, midX, midY); + break; + case SKEncodedOrigin.BottomLeft: + surface.Scale(1, -1, midX, midY); + break; + case SKEncodedOrigin.LeftTop: + surface.Translate(0, -rotated.Height); + surface.Scale(1, -1, midX, midY); + surface.RotateDegrees(-90); + break; + case SKEncodedOrigin.RightTop: + surface.Translate(rotated.Width, 0); + surface.RotateDegrees(90); + break; + case SKEncodedOrigin.RightBottom: + surface.Translate(rotated.Width, 0); + surface.Scale(1, -1, midX, midY); + surface.RotateDegrees(90); + break; + case SKEncodedOrigin.LeftBottom: + surface.Translate(0, rotated.Height); + surface.RotateDegrees(-90); + break; + } + + surface.DrawBitmap(bitmap, 0, 0); + return rotated; + } + + /// <summary> + /// Resizes an image on the CPU, by utilizing a surface and canvas. + /// + /// The convolutional matrix kernel used in this resize function gives a (light) sharpening effect. + /// This technique is similar to effect that can be created using for example the [Convolution matrix filter in GIMP](https://docs.gimp.org/2.10/en/gimp-filter-convolution-matrix.html). + /// </summary> + /// <param name="source">The source bitmap.</param> + /// <param name="targetInfo">This specifies the target size and other information required to create the surface.</param> + /// <param name="isAntialias">This enables anti-aliasing on the SKPaint instance.</param> + /// <param name="isDither">This enables dithering on the SKPaint instance.</param> + /// <returns>The resized image.</returns> + internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false) + { + using var surface = SKSurface.Create(targetInfo); + using var canvas = surface.Canvas; + using var paint = new SKPaint + { + FilterQuality = SKFilterQuality.High, + IsAntialias = isAntialias, + IsDither = isDither + }; + + var kernel = new float[9] + { + 0, -.1f, 0, + -.1f, 1.4f, -.1f, + 0, -.1f, 0, + }; + + var kernelSize = new SKSizeI(3, 3); + var kernelOffset = new SKPointI(1, 1); + + paint.ImageFilter = SKImageFilter.CreateMatrixConvolution( + kernelSize, + kernel, + 1f, + 0f, + kernelOffset, + SKShaderTileMode.Clamp, + true); + + canvas.DrawBitmap( + source, + SKRect.Create(0, 0, source.Width, source.Height), + SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height), + paint); + + return surface.Snapshot(); + } + + /// <inheritdoc/> + public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) + { + ArgumentException.ThrowIfNullOrEmpty(inputPath); + ArgumentException.ThrowIfNullOrEmpty(outputPath); + + var inputFormat = Path.GetExtension(inputPath.AsSpan()).TrimStart('.'); + if (!SupportedInputFormats.Contains(inputFormat, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Unable to encode image due to unsupported format: {ImagePath}", inputPath); + return inputPath; + } + + var skiaOutputFormat = GetImageFormat(outputFormat); + + var hasBackgroundColor = !string.IsNullOrWhiteSpace(options.BackgroundColor); + var hasForegroundColor = !string.IsNullOrWhiteSpace(options.ForegroundLayer); + var blur = options.Blur ?? 0; + var hasIndicator = options.AddPlayedIndicator || options.UnplayedCount.HasValue || !options.PercentPlayed.Equals(0); + + using var bitmap = GetBitmap(inputPath, autoOrient, orientation); + if (bitmap is null) + { + throw new InvalidDataException($"Skia unable to read image {inputPath}"); + } + + var originalImageSize = new ImageDimensions(bitmap.Width, bitmap.Height); + + if (options.HasDefaultOptions(inputPath, originalImageSize) && !autoOrient) + { + // Just spit out the original file if all the options are default + return inputPath; + } + + var newImageSize = ImageHelper.GetNewImageSize(options, originalImageSize); + + var width = newImageSize.Width; + var height = newImageSize.Height; + + // scale image (the FromImage creates a copy) + var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace); + using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo)); + + // If all we're doing is resizing then we can stop now + if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) + { + var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(outputDirectory); + using var outputStream = new SKFileWStream(outputPath); + using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels()); + resizedBitmap.Encode(outputStream, skiaOutputFormat, quality); + return outputPath; + } + + // create bitmap to use for canvas drawing used to draw into bitmap + using var saveBitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(saveBitmap); + // set background color if present + if (hasBackgroundColor) + { + canvas.Clear(SKColor.Parse(options.BackgroundColor)); + } + + // Add blur if option is present + if (blur > 0) + { + // create image from resized bitmap to apply blur + using var paint = new SKPaint(); + using var filter = SKImageFilter.CreateBlur(blur, blur); + paint.ImageFilter = filter; + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint); + } + else + { + // draw resized bitmap onto canvas + canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height)); + } + + // If foreground layer present then draw + if (hasForegroundColor) + { + if (!double.TryParse(options.ForegroundLayer, out double opacity)) + { + opacity = .4; + } + + canvas.DrawColor(new SKColor(0, 0, 0, (byte)((1 - opacity) * 0xFF)), SKBlendMode.SrcOver); + } + + if (hasIndicator) + { + DrawIndicator(canvas, width, height, options); + } + + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); + using (var outputStream = new SKFileWStream(outputPath)) + { + using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) + { + pixmap.Encode(outputStream, skiaOutputFormat, quality); + } + } + + return outputPath; + } + + /// <inheritdoc/> + public void CreateImageCollage(ImageCollageOptions options, string? libraryName) + { + double ratio = (double)options.Width / options.Height; + + if (ratio >= 1.4) + { + new StripCollageBuilder(this).BuildThumbCollage(options.InputPaths, options.OutputPath, options.Width, options.Height, libraryName); + } + else if (ratio >= .9) + { + new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); + } + else + { + // TODO: Create Poster collage capability + new StripCollageBuilder(this).BuildSquareCollage(options.InputPaths, options.OutputPath, options.Width, options.Height); + } + } + + /// <inheritdoc /> + public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) + { + var splashBuilder = new SplashscreenBuilder(this); + var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png"); + splashBuilder.GenerateSplash(posters, backdrops, outputPath); + } + + private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options) + { + try + { + var currentImageSize = new ImageDimensions(imageWidth, imageHeight); + + if (options.AddPlayedIndicator) + { + PlayedIndicatorDrawer.DrawPlayedIndicator(canvas, currentImageSize); + } + else if (options.UnplayedCount.HasValue) + { + UnplayedCountIndicator.DrawUnplayedCountIndicator(canvas, currentImageSize, options.UnplayedCount.Value); + } + + if (options.PercentPlayed > 0) + { + PercentPlayedDrawer.Process(canvas, currentImageSize, options.PercentPlayed); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error drawing indicator overlay"); + } + } +} diff --git a/src/Jellyfin.Drawing.Skia/SkiaException.cs b/src/Jellyfin.Drawing.Skia/SkiaException.cs new file mode 100644 index 000000000..d0e69d42c --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/SkiaException.cs @@ -0,0 +1,38 @@ +using System; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Represents errors that occur during interaction with Skia. +/// </summary> +public class SkiaException : Exception +{ + /// <summary> + /// Initializes a new instance of the <see cref="SkiaException"/> class. + /// </summary> + public SkiaException() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message. + /// </summary> + /// <param name="message">The message that describes the error.</param> + public SkiaException(string message) : base(message) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a + /// reference to the inner exception that is the cause of this exception. + /// </summary> + /// <param name="message">The error message that explains the reason for the exception.</param> + /// <param name="innerException"> + /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if + /// no inner exception is specified. + /// </param> + public SkiaException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs new file mode 100644 index 000000000..00d224da9 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Class containing helper methods for working with SkiaSharp. +/// </summary> +public static class SkiaHelper +{ + /// <summary> + /// Gets the next valid image as a bitmap. + /// </summary> + /// <param name="skiaEncoder">The current skia encoder.</param> + /// <param name="paths">The list of image paths.</param> + /// <param name="currentIndex">The current checked index.</param> + /// <param name="newIndex">The new index.</param> + /// <returns>A valid bitmap, or null if no bitmap exists after <c>currentIndex</c>.</returns> + public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex) + { + var imagesTested = new Dictionary<int, int>(); + SKBitmap? bitmap = null; + + while (imagesTested.Count < paths.Count) + { + if (currentIndex >= paths.Count) + { + currentIndex = 0; + } + + bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _); + + imagesTested[currentIndex] = 0; + + currentIndex++; + + if (bitmap is not null) + { + break; + } + } + + newIndex = currentIndex; + return bitmap; + } +} diff --git a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs new file mode 100644 index 000000000..990556623 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Used to build the splashscreen. +/// </summary> +public class SplashscreenBuilder +{ + private const int FinalWidth = 1920; + private const int FinalHeight = 1080; + // generated collage resolution should be higher than the final resolution + private const int WallWidth = FinalWidth * 3; + private const int WallHeight = FinalHeight * 2; + private const int Rows = 6; + private const int Spacing = 20; + + private readonly SkiaEncoder _skiaEncoder; + + /// <summary> + /// Initializes a new instance of the <see cref="SplashscreenBuilder"/> class. + /// </summary> + /// <param name="skiaEncoder">The SkiaEncoder.</param> + public SplashscreenBuilder(SkiaEncoder skiaEncoder) + { + _skiaEncoder = skiaEncoder; + } + + /// <summary> + /// Generate a splashscreen. + /// </summary> + /// <param name="posters">The poster paths.</param> + /// <param name="backdrops">The landscape paths.</param> + /// <param name="outputPath">The output path.</param> + public void GenerateSplash(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops, string outputPath) + { + using var wall = GenerateCollage(posters, backdrops); + using var transformed = Transform3D(wall); + + using var outputStream = new SKFileWStream(outputPath); + using var pixmap = new SKPixmap(new SKImageInfo(FinalWidth, FinalHeight), transformed.GetPixels()); + pixmap.Encode(outputStream, StripCollageBuilder.GetEncodedFormat(outputPath), 90); + } + + /// <summary> + /// Generates a collage of posters and landscape pictures. + /// </summary> + /// <param name="posters">The poster paths.</param> + /// <param name="backdrops">The landscape paths.</param> + /// <returns>The created collage as a bitmap.</returns> + private SKBitmap GenerateCollage(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) + { + var posterIndex = 0; + var backdropIndex = 0; + + var bitmap = new SKBitmap(WallWidth, WallHeight); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Black); + + int posterHeight = WallHeight / 6; + + for (int i = 0; i < Rows; i++) + { + int imageCounter = Random.Shared.Next(0, 5); + int currentWidthPos = i * 75; + int currentHeight = i * (posterHeight + Spacing); + + while (currentWidthPos < WallWidth) + { + SKBitmap? currentImage; + + switch (imageCounter) + { + case 0: + case 2: + case 3: + currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, posters, posterIndex, out int newPosterIndex); + posterIndex = newPosterIndex; + break; + default: + currentImage = SkiaHelper.GetNextValidImage(_skiaEncoder, backdrops, backdropIndex, out int newBackdropIndex); + backdropIndex = newBackdropIndex; + break; + } + + if (currentImage is null) + { + throw new ArgumentException("Not enough valid pictures provided to create a splashscreen!"); + } + + // resize to the same aspect as the original + var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height); + using var resizedBitmap = new SKBitmap(imageWidth, posterHeight); + currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High); + + // draw on canvas + canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight); + + currentWidthPos += imageWidth + Spacing; + + currentImage.Dispose(); + + if (imageCounter >= 4) + { + imageCounter = 0; + } + else + { + imageCounter++; + } + } + } + + return bitmap; + } + + /// <summary> + /// Transform the collage in 3D space. + /// </summary> + /// <param name="input">The bitmap to transform.</param> + /// <returns>The transformed image.</returns> + private SKBitmap Transform3D(SKBitmap input) + { + var bitmap = new SKBitmap(FinalWidth, FinalHeight); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Black); + var matrix = new SKMatrix + { + ScaleX = 0.324108899f, + ScaleY = 0.563934922f, + SkewX = -0.244337708f, + SkewY = 0.0377609022f, + TransX = 42.0407715f, + TransY = -198.104706f, + Persp0 = -9.08959337E-05f, + Persp1 = 6.85242048E-05f, + Persp2 = 0.988209724f + }; + + canvas.SetMatrix(matrix); + canvas.DrawBitmap(input, 0, 0); + + return bitmap; + } +} diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs new file mode 100644 index 000000000..eee24c423 --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Used to build collages of multiple images arranged in vertical strips. +/// </summary> +public class StripCollageBuilder +{ + private readonly SkiaEncoder _skiaEncoder; + + /// <summary> + /// Initializes a new instance of the <see cref="StripCollageBuilder"/> class. + /// </summary> + /// <param name="skiaEncoder">The encoder to use for building collages.</param> + public StripCollageBuilder(SkiaEncoder skiaEncoder) + { + _skiaEncoder = skiaEncoder; + } + + /// <summary> + /// Check which format an image has been encoded with using its filename extension. + /// </summary> + /// <param name="outputPath">The path to the image to get the format for.</param> + /// <returns>The image format.</returns> + public static SKEncodedImageFormat GetEncodedFormat(string outputPath) + { + ArgumentNullException.ThrowIfNull(outputPath); + + var ext = Path.GetExtension(outputPath); + + if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase) + || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase)) + { + return SKEncodedImageFormat.Jpeg; + } + + if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase)) + { + return SKEncodedImageFormat.Webp; + } + + if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase)) + { + return SKEncodedImageFormat.Gif; + } + + if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase)) + { + return SKEncodedImageFormat.Bmp; + } + + // default to png + return SKEncodedImageFormat.Png; + } + + /// <summary> + /// Create a square collage. + /// </summary> + /// <param name="paths">The paths of the images to use in the collage.</param> + /// <param name="outputPath">The path at which to place the resulting collage image.</param> + /// <param name="width">The desired width of the collage.</param> + /// <param name="height">The desired height of the collage.</param> + public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height) + { + using var bitmap = BuildSquareCollageBitmap(paths, width, height); + using var outputStream = new SKFileWStream(outputPath); + using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()); + pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); + } + + /// <summary> + /// Create a thumb collage. + /// </summary> + /// <param name="paths">The paths of the images to use in the collage.</param> + /// <param name="outputPath">The path at which to place the resulting image.</param> + /// <param name="width">The desired width of the collage.</param> + /// <param name="height">The desired height of the collage.</param> + /// <param name="libraryName">The name of the library to draw on the collage.</param> + public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName) + { + using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName); + using var outputStream = new SKFileWStream(outputPath); + using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels()); + pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90); + } + + private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName) + { + var bitmap = new SKBitmap(width, height); + + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Black); + + using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _); + if (backdrop is null) + { + return bitmap; + } + + // resize to the same aspect as the original + var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width); + using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace)); + // draw the backdrop + canvas.DrawImage(residedBackdrop, 0, 0); + + // draw shadow rectangle + using var paintColor = new SKPaint + { + Color = SKColors.Black.WithAlpha(0x78), + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(0, 0, width, height, paintColor); + + var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); + + // use the system fallback to find a typeface for the given CJK character + var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]"; + var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty); + if (!string.IsNullOrEmpty(filteredName)) + { + typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]); + } + + // draw library name + using var textPaint = new SKPaint + { + Color = SKColors.White, + Style = SKPaintStyle.Fill, + TextSize = 112, + TextAlign = SKTextAlign.Center, + Typeface = typeFace, + IsAntialias = true + }; + + // scale down text to 90% of the width if text is larger than 95% of the width + var textWidth = textPaint.MeasureText(libraryName); + if (textWidth > width * 0.95) + { + textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth; + } + + canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + + return bitmap; + } + + private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height) + { + var bitmap = new SKBitmap(width, height); + var imageIndex = 0; + var cellWidth = width / 2; + var cellHeight = height / 2; + + using var canvas = new SKCanvas(bitmap); + for (var x = 0; x < 2; x++) + { + for (var y = 0; y < 2; y++) + { + using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex); + imageIndex = newIndex; + + if (currentBitmap is null) + { + continue; + } + + // Scale image. The FromBitmap creates a copy + var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace); + using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo)); + + // draw this image into the strip at the next position + var xPos = x * cellWidth; + var yPos = y * cellHeight; + canvas.DrawBitmap(resizedBitmap, xPos, yPos); + } + } + + return bitmap; + } +} diff --git a/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs new file mode 100644 index 000000000..456b84b8c --- /dev/null +++ b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs @@ -0,0 +1,63 @@ +using System.Globalization; +using MediaBrowser.Model.Drawing; +using SkiaSharp; + +namespace Jellyfin.Drawing.Skia; + +/// <summary> +/// Static helper class for drawing unplayed count indicators. +/// </summary> +public static class UnplayedCountIndicator +{ + /// <summary> + /// The x-offset used when drawing an unplayed count indicator. + /// </summary> + private const int OffsetFromTopRightCorner = 38; + + /// <summary> + /// Draw an unplayed count indicator in the top right corner of a canvas. + /// </summary> + /// <param name="canvas">The canvas to draw the indicator on.</param> + /// <param name="imageSize"> + /// The dimensions of the image to draw the indicator on. The width is used to determine the x-position of the + /// indicator. + /// </param> + /// <param name="count">The number to draw in the indicator.</param> + public static void DrawUnplayedCountIndicator(SKCanvas canvas, ImageDimensions imageSize, int count) + { + var x = imageSize.Width - OffsetFromTopRightCorner; + var text = count.ToString(CultureInfo.InvariantCulture); + + using var paint = new SKPaint + { + Color = SKColor.Parse("#CC00A4DC"), + Style = SKPaintStyle.Fill + }; + + canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint); + + paint.Color = new SKColor(255, 255, 255, 255); + paint.TextSize = 24; + paint.IsAntialias = true; + + var y = OffsetFromTopRightCorner + 9; + + if (text.Length == 1) + { + x -= 7; + } + + if (text.Length == 2) + { + x -= 13; + } + else if (text.Length >= 3) + { + x -= 15; + y -= 2; + paint.TextSize = 18; + } + + canvas.DrawText(text, x, y, paint); + } +} diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 3c7bc394f..353a27b25 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -5,10 +5,12 @@ using System.IO; using System.Linq; using System.Net.Mime; using System.Text; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.MediaEncoding; @@ -19,551 +21,575 @@ using MediaBrowser.Model.Net; using Microsoft.Extensions.Logging; using Photo = MediaBrowser.Controller.Entities.Photo; -namespace Jellyfin.Drawing +namespace Jellyfin.Drawing; + +/// <summary> +/// Class ImageProcessor. +/// </summary> +public sealed class ImageProcessor : IImageProcessor, IDisposable { + // Increment this when there's a change requiring caches to be invalidated + private const char Version = '3'; + + private static readonly HashSet<string> _transparentImageTypes + = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" }; + + private readonly ILogger<ImageProcessor> _logger; + private readonly IFileSystem _fileSystem; + private readonly IServerApplicationPaths _appPaths; + private readonly IImageEncoder _imageEncoder; + private readonly IMediaEncoder _mediaEncoder; + + private readonly SemaphoreSlim _parallelEncodingLimit; + + private bool _disposed; + /// <summary> - /// Class ImageProcessor. + /// Initializes a new instance of the <see cref="ImageProcessor"/> class. /// </summary> - public sealed class ImageProcessor : IImageProcessor, IDisposable + /// <param name="logger">The logger.</param> + /// <param name="appPaths">The server application paths.</param> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="imageEncoder">The image encoder.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="config">The configuration.</param> + public ImageProcessor( + ILogger<ImageProcessor> logger, + IServerApplicationPaths appPaths, + IFileSystem fileSystem, + IImageEncoder imageEncoder, + IMediaEncoder mediaEncoder, + IServerConfigurationManager config) { - // Increment this when there's a change requiring caches to be invalidated - private const char Version = '3'; - - private static readonly HashSet<string> _transparentImageTypes - = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" }; - - private readonly ILogger<ImageProcessor> _logger; - private readonly IFileSystem _fileSystem; - private readonly IServerApplicationPaths _appPaths; - private readonly IImageEncoder _imageEncoder; - private readonly IMediaEncoder _mediaEncoder; - - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="ImageProcessor"/> class. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="appPaths">The server application paths.</param> - /// <param name="fileSystem">The filesystem.</param> - /// <param name="imageEncoder">The image encoder.</param> - /// <param name="mediaEncoder">The media encoder.</param> - public ImageProcessor( - ILogger<ImageProcessor> logger, - IServerApplicationPaths appPaths, - IFileSystem fileSystem, - IImageEncoder imageEncoder, - IMediaEncoder mediaEncoder) - { - _logger = logger; - _fileSystem = fileSystem; - _imageEncoder = imageEncoder; - _mediaEncoder = mediaEncoder; - _appPaths = appPaths; - } - - private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); - - /// <inheritdoc /> - public IReadOnlyCollection<string> SupportedInputFormats => - new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "tiff", - "tif", - "jpeg", - "jpg", - "png", - "aiff", - "cr2", - "crw", - "nef", - "orf", - "pef", - "arw", - "webp", - "gif", - "bmp", - "erf", - "raf", - "rw2", - "nrw", - "dng", - "ico", - "astc", - "ktx", - "pkm", - "wbmp" - }; - - /// <inheritdoc /> - public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation; - - /// <inheritdoc /> - public async Task ProcessImage(ImageProcessingOptions options, Stream toStream) - { - var file = await ProcessImage(options).ConfigureAwait(false); - using (var fileStream = AsyncFile.OpenRead(file.Path)) - { - await fileStream.CopyToAsync(toStream).ConfigureAwait(false); - } + _logger = logger; + _fileSystem = fileSystem; + _imageEncoder = imageEncoder; + _mediaEncoder = mediaEncoder; + _appPaths = appPaths; + + var semaphoreCount = config.Configuration.ParallelImageEncodingLimit; + if (semaphoreCount < 1) + { + semaphoreCount = 2 * Environment.ProcessorCount; } - /// <inheritdoc /> - public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats() - => _imageEncoder.SupportedOutputFormats; + _parallelEncodingLimit = new(semaphoreCount, semaphoreCount); + } - /// <inheritdoc /> - public bool SupportsTransparency(string path) - => _transparentImageTypes.Contains(Path.GetExtension(path)); + private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); - /// <inheritdoc /> - public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options) + /// <inheritdoc /> + public IReadOnlyCollection<string> SupportedInputFormats => + new HashSet<string>(StringComparer.OrdinalIgnoreCase) { - ItemImageInfo originalImage = options.Image; - BaseItem item = options.Item; + "tiff", + "tif", + "jpeg", + "jpg", + "png", + "aiff", + "cr2", + "crw", + "nef", + "orf", + "pef", + "arw", + "webp", + "gif", + "bmp", + "erf", + "raf", + "rw2", + "nrw", + "dng", + "ico", + "astc", + "ktx", + "pkm", + "wbmp" + }; + + /// <inheritdoc /> + public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation; + + /// <inheritdoc /> + public async Task ProcessImage(ImageProcessingOptions options, Stream toStream) + { + var file = await ProcessImage(options).ConfigureAwait(false); + using (var fileStream = AsyncFile.OpenRead(file.Path)) + { + await fileStream.CopyToAsync(toStream).ConfigureAwait(false); + } + } - string originalImagePath = originalImage.Path; - DateTime dateModified = originalImage.DateModified; - ImageDimensions? originalImageSize = null; - if (originalImage.Width > 0 && originalImage.Height > 0) - { - originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height); - } + /// <inheritdoc /> + public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats() + => _imageEncoder.SupportedOutputFormats; - var mimeType = MimeTypes.GetMimeType(originalImagePath); - if (!_imageEncoder.SupportsImageEncoding) - { - return (originalImagePath, mimeType, dateModified); - } + /// <inheritdoc /> + public bool SupportsTransparency(string path) + => _transparentImageTypes.Contains(Path.GetExtension(path)); - var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false); - originalImagePath = supportedImageInfo.Path; + /// <inheritdoc /> + public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options) + { + ItemImageInfo originalImage = options.Image; + BaseItem item = options.Item; - // Original file doesn't exist, or original file is gif. - if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase)) - { - return (originalImagePath, mimeType, dateModified); - } + string originalImagePath = originalImage.Path; + DateTime dateModified = originalImage.DateModified; + ImageDimensions? originalImageSize = null; + if (originalImage.Width > 0 && originalImage.Height > 0) + { + originalImageSize = new ImageDimensions(originalImage.Width, originalImage.Height); + } - dateModified = supportedImageInfo.DateModified; - bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath)); + var mimeType = MimeTypes.GetMimeType(originalImagePath); + if (!_imageEncoder.SupportsImageEncoding) + { + return (originalImagePath, mimeType, dateModified); + } - bool autoOrient = false; - ImageOrientation? orientation = null; - if (item is Photo photo) - { - if (photo.Orientation.HasValue) - { - if (photo.Orientation.Value != ImageOrientation.TopLeft) - { - autoOrient = true; - orientation = photo.Orientation; - } - } - else - { - // Orientation unknown, so do it - autoOrient = true; - orientation = photo.Orientation; - } - } + var supportedImageInfo = await GetSupportedImage(originalImagePath, dateModified).ConfigureAwait(false); + originalImagePath = supportedImageInfo.Path; - if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation)) - { - // Just spit out the original file if all the options are default - return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); - } + // Original file doesn't exist, or original file is gif. + if (!File.Exists(originalImagePath) || string.Equals(mimeType, MediaTypeNames.Image.Gif, StringComparison.OrdinalIgnoreCase)) + { + return (originalImagePath, mimeType, dateModified); + } + + dateModified = supportedImageInfo.DateModified; + bool requiresTransparency = _transparentImageTypes.Contains(Path.GetExtension(originalImagePath)); - int quality = options.Quality; - - ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency); - string cacheFilePath = GetCacheFilePath( - originalImagePath, - options.Width, - options.Height, - options.MaxWidth, - options.MaxHeight, - options.FillWidth, - options.FillHeight, - quality, - dateModified, - outputFormat, - options.AddPlayedIndicator, - options.PercentPlayed, - options.UnplayedCount, - options.Blur, - options.BackgroundColor, - options.ForegroundLayer); - - try + bool autoOrient = false; + ImageOrientation? orientation = null; + if (item is Photo photo) + { + if (photo.Orientation.HasValue) { - if (!File.Exists(cacheFilePath)) + if (photo.Orientation.Value != ImageOrientation.TopLeft) { - string resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); - - if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) - { - return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); - } + autoOrient = true; + orientation = photo.Orientation; } - - return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); } - catch (Exception ex) + else { - // If it fails for whatever reason, return the original image - _logger.LogError(ex, "Error encoding image"); - return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + // Orientation unknown, so do it + autoOrient = true; + orientation = photo.Orientation; } } - private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency) + if (options.HasDefaultOptions(originalImagePath, originalImageSize) && (!autoOrient || !options.RequiresAutoOrientation)) { - var serverFormats = GetSupportedImageOutputFormats(); + // Just spit out the original file if all the options are default + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); + } - // Client doesn't care about format, so start with webp if supported - if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp)) + int quality = options.Quality; + + ImageFormat outputFormat = GetOutputFormat(options.SupportedOutputFormats, requiresTransparency); + string cacheFilePath = GetCacheFilePath( + originalImagePath, + options.Width, + options.Height, + options.MaxWidth, + options.MaxHeight, + options.FillWidth, + options.FillHeight, + quality, + dateModified, + outputFormat, + options.AddPlayedIndicator, + options.PercentPlayed, + options.UnplayedCount, + options.Blur, + options.BackgroundColor, + options.ForegroundLayer); + + try + { + if (!File.Exists(cacheFilePath)) { - return ImageFormat.Webp; - } + // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage + await _parallelEncodingLimit.WaitAsync().ConfigureAwait(false); - // If transparency is needed and webp isn't supported, than png is the only option - if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png)) - { - return ImageFormat.Png; - } + string resultPath; + try + { + resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); + } + finally + { + _parallelEncodingLimit.Release(); + } - foreach (var format in clientSupportedFormats) - { - if (serverFormats.Contains(format)) + if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) { - return format; + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } } - // We should never actually get here - return ImageFormat.Jpg; + return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); + } + catch (Exception ex) + { + // If it fails for whatever reason, return the original image + _logger.LogError(ex, "Error encoding image"); + return (originalImagePath, MimeTypes.GetMimeType(originalImagePath), dateModified); } + } - private string GetMimeType(ImageFormat format, string path) - => format switch - { - ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"), - ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"), - ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"), - ImageFormat.Png => MimeTypes.GetMimeType("i.png"), - ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"), - _ => MimeTypes.GetMimeType(path) - }; - - /// <summary> - /// Gets the cache file path based on a set of parameters. - /// </summary> - private string GetCacheFilePath( - string originalPath, - int? width, - int? height, - int? maxWidth, - int? maxHeight, - int? fillWidth, - int? fillHeight, - int quality, - DateTime dateModified, - ImageFormat format, - bool addPlayedIndicator, - double percentPlayed, - int? unwatchedCount, - int? blur, - string backgroundColor, - string foregroundLayer) - { - var filename = new StringBuilder(256); - filename.Append(originalPath); - - filename.Append(",quality="); - filename.Append(quality); - - filename.Append(",datemodified="); - filename.Append(dateModified.Ticks); - - filename.Append(",f="); - filename.Append(format); - - if (width.HasValue) - { - filename.Append(",width="); - filename.Append(width.Value); - } + private ImageFormat GetOutputFormat(IReadOnlyCollection<ImageFormat> clientSupportedFormats, bool requiresTransparency) + { + var serverFormats = GetSupportedImageOutputFormats(); - if (height.HasValue) - { - filename.Append(",height="); - filename.Append(height.Value); - } + // Client doesn't care about format, so start with webp if supported + if (serverFormats.Contains(ImageFormat.Webp) && clientSupportedFormats.Contains(ImageFormat.Webp)) + { + return ImageFormat.Webp; + } - if (maxWidth.HasValue) - { - filename.Append(",maxwidth="); - filename.Append(maxWidth.Value); - } + // If transparency is needed and webp isn't supported, than png is the only option + if (requiresTransparency && clientSupportedFormats.Contains(ImageFormat.Png)) + { + return ImageFormat.Png; + } - if (maxHeight.HasValue) + foreach (var format in clientSupportedFormats) + { + if (serverFormats.Contains(format)) { - filename.Append(",maxheight="); - filename.Append(maxHeight.Value); + return format; } + } - if (fillWidth.HasValue) - { - filename.Append(",fillwidth="); - filename.Append(fillWidth.Value); - } + // We should never actually get here + return ImageFormat.Jpg; + } - if (fillHeight.HasValue) - { - filename.Append(",fillheight="); - filename.Append(fillHeight.Value); - } + private string GetMimeType(ImageFormat format, string path) + => format switch + { + ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"), + ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"), + ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"), + ImageFormat.Png => MimeTypes.GetMimeType("i.png"), + ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"), + _ => MimeTypes.GetMimeType(path) + }; - if (addPlayedIndicator) - { - filename.Append(",pl=true"); - } + /// <summary> + /// Gets the cache file path based on a set of parameters. + /// </summary> + private string GetCacheFilePath( + string originalPath, + int? width, + int? height, + int? maxWidth, + int? maxHeight, + int? fillWidth, + int? fillHeight, + int quality, + DateTime dateModified, + ImageFormat format, + bool addPlayedIndicator, + double percentPlayed, + int? unwatchedCount, + int? blur, + string backgroundColor, + string foregroundLayer) + { + var filename = new StringBuilder(256); + filename.Append(originalPath); - if (percentPlayed > 0) - { - filename.Append(",p="); - filename.Append(percentPlayed); - } + filename.Append(",quality="); + filename.Append(quality); - if (unwatchedCount.HasValue) - { - filename.Append(",p="); - filename.Append(unwatchedCount.Value); - } + filename.Append(",datemodified="); + filename.Append(dateModified.Ticks); - if (blur.HasValue) - { - filename.Append(",blur="); - filename.Append(blur.Value); - } + filename.Append(",f="); + filename.Append(format); - if (!string.IsNullOrEmpty(backgroundColor)) - { - filename.Append(",b="); - filename.Append(backgroundColor); - } + if (width.HasValue) + { + filename.Append(",width="); + filename.Append(width.Value); + } - if (!string.IsNullOrEmpty(foregroundLayer)) - { - filename.Append(",fl="); - filename.Append(foregroundLayer); - } + if (height.HasValue) + { + filename.Append(",height="); + filename.Append(height.Value); + } - filename.Append(",v="); - filename.Append(Version); + if (maxWidth.HasValue) + { + filename.Append(",maxwidth="); + filename.Append(maxWidth.Value); + } - return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant()); + if (maxHeight.HasValue) + { + filename.Append(",maxheight="); + filename.Append(maxHeight.Value); } - /// <inheritdoc /> - public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info) + if (fillWidth.HasValue) { - int width = info.Width; - int height = info.Height; + filename.Append(",fillwidth="); + filename.Append(fillWidth.Value); + } - if (height > 0 && width > 0) - { - return new ImageDimensions(width, height); - } + if (fillHeight.HasValue) + { + filename.Append(",fillheight="); + filename.Append(fillHeight.Value); + } - string path = info.Path; - _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path); + if (addPlayedIndicator) + { + filename.Append(",pl=true"); + } - ImageDimensions size = GetImageDimensions(path); - info.Width = size.Width; - info.Height = size.Height; + if (percentPlayed > 0) + { + filename.Append(",p="); + filename.Append(percentPlayed); + } - return size; + if (unwatchedCount.HasValue) + { + filename.Append(",p="); + filename.Append(unwatchedCount.Value); } - /// <inheritdoc /> - public ImageDimensions GetImageDimensions(string path) - => _imageEncoder.GetImageSize(path); + if (blur.HasValue) + { + filename.Append(",blur="); + filename.Append(blur.Value); + } - /// <inheritdoc /> - public string GetImageBlurHash(string path) + if (!string.IsNullOrEmpty(backgroundColor)) { - var size = GetImageDimensions(path); - return GetImageBlurHash(path, size); + filename.Append(",b="); + filename.Append(backgroundColor); } - /// <inheritdoc /> - public string GetImageBlurHash(string path, ImageDimensions imageDimensions) + if (!string.IsNullOrEmpty(foregroundLayer)) { - if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0) - { - return string.Empty; - } + filename.Append(",fl="); + filename.Append(foregroundLayer); + } - // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance. - // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width. - // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components - float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height); - float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width; + filename.Append(",v="); + filename.Append(Version); - int xComp = Math.Min((int)xCompF + 1, 9); - int yComp = Math.Min((int)yCompF + 1, 9); + return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant()); + } - return _imageEncoder.GetImageBlurHash(xComp, yComp, path); + /// <inheritdoc /> + public ImageDimensions GetImageDimensions(BaseItem item, ItemImageInfo info) + { + int width = info.Width; + int height = info.Height; + + if (height > 0 && width > 0) + { + return new ImageDimensions(width, height); } - /// <inheritdoc /> - public string GetImageCacheTag(BaseItem item, ItemImageInfo image) - => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); + string path = info.Path; + _logger.LogDebug("Getting image size for item {ItemType} {Path}", item.GetType().Name, path); + + ImageDimensions size = GetImageDimensions(path); + info.Width = size.Width; + info.Height = size.Height; + + return size; + } + + /// <inheritdoc /> + public ImageDimensions GetImageDimensions(string path) + => _imageEncoder.GetImageSize(path); + + /// <inheritdoc /> + public string GetImageBlurHash(string path) + { + var size = GetImageDimensions(path); + return GetImageBlurHash(path, size); + } - /// <inheritdoc /> - public string GetImageCacheTag(BaseItem item, ChapterInfo chapter) + /// <inheritdoc /> + public string GetImageBlurHash(string path, ImageDimensions imageDimensions) + { + if (imageDimensions.Width <= 0 || imageDimensions.Height <= 0) { - return GetImageCacheTag(item, new ItemImageInfo - { - Path = chapter.ImagePath, - Type = ImageType.Chapter, - DateModified = chapter.ImageDateModified - }); + return string.Empty; } - /// <inheritdoc /> - public string? GetImageCacheTag(User user) - { - if (user.ProfileImage is null) - { - return null; - } + // We want tiles to be as close to square as possible, and to *mostly* keep under 16 tiles for performance. + // One tile is (width / xComp) x (height / yComp) pixels, which means that ideally yComp = xComp * height / width. + // See more at https://github.com/woltapp/blurhash/#how-do-i-pick-the-number-of-x-and-y-components + float xCompF = MathF.Sqrt(16.0f * imageDimensions.Width / imageDimensions.Height); + float yCompF = xCompF * imageDimensions.Height / imageDimensions.Width; - return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5() - .ToString("N", CultureInfo.InvariantCulture); - } + int xComp = Math.Min((int)xCompF + 1, 9); + int yComp = Math.Min((int)yCompF + 1, 9); - private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) + return _imageEncoder.GetImageBlurHash(xComp, yComp, path); + } + + /// <inheritdoc /> + public string GetImageCacheTag(BaseItem item, ItemImageInfo image) + => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); + + /// <inheritdoc /> + public string GetImageCacheTag(BaseItem item, ChapterInfo chapter) + { + return GetImageCacheTag(item, new ItemImageInfo { - var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString(); + Path = chapter.ImagePath, + Type = ImageType.Chapter, + DateModified = chapter.ImageDateModified + }); + } - // These are just jpg files renamed as tbn - if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult((originalImagePath, dateModified)); - } + /// <inheritdoc /> + public string? GetImageCacheTag(User user) + { + if (user.ProfileImage is null) + { + return null; + } + + return (user.ProfileImage.Path + user.ProfileImage.LastModified.Ticks).GetMD5() + .ToString("N", CultureInfo.InvariantCulture); + } - // TODO _mediaEncoder.ConvertImage is not implemented - // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat)) - // { - // try - // { - // string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture); - // - // string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png"; - // var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension); - // - // var file = _fileSystem.GetFileInfo(outputPath); - // if (!file.Exists) - // { - // await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false); - // dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath); - // } - // else - // { - // dateModified = file.LastWriteTimeUtc; - // } - // - // originalImagePath = outputPath; - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath); - // } - // } + private Task<(string Path, DateTime DateModified)> GetSupportedImage(string originalImagePath, DateTime dateModified) + { + var inputFormat = Path.GetExtension(originalImagePath.AsSpan()).TrimStart('.').ToString(); + // These are just jpg files renamed as tbn + if (string.Equals(inputFormat, "tbn", StringComparison.OrdinalIgnoreCase)) + { return Task.FromResult((originalImagePath, dateModified)); } - /// <summary> - /// Gets the cache path. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="uniqueName">Name of the unique.</param> - /// <param name="fileExtension">The file extension.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException"> - /// path - /// or - /// uniqueName - /// or - /// fileExtension. - /// </exception> - public string GetCachePath(string path, string uniqueName, string fileExtension) - { - ArgumentException.ThrowIfNullOrEmpty(path); - ArgumentException.ThrowIfNullOrEmpty(uniqueName); - ArgumentException.ThrowIfNullOrEmpty(fileExtension); - - var filename = uniqueName.GetMD5() + fileExtension; - - return GetCachePath(path, filename); - } - - /// <summary> - /// Gets the cache path. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="filename">The filename.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException"> - /// path - /// or - /// filename. - /// </exception> - public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename) - { - if (path.IsEmpty) - { - throw new ArgumentException("Path can't be empty.", nameof(path)); - } + // TODO _mediaEncoder.ConvertImage is not implemented + // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat)) + // { + // try + // { + // string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture); + // + // string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png"; + // var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension); + // + // var file = _fileSystem.GetFileInfo(outputPath); + // if (!file.Exists) + // { + // await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false); + // dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath); + // } + // else + // { + // dateModified = file.LastWriteTimeUtc; + // } + // + // originalImagePath = outputPath; + // } + // catch (Exception ex) + // { + // _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath); + // } + // } + + return Task.FromResult((originalImagePath, dateModified)); + } - if (filename.IsEmpty) - { - throw new ArgumentException("Filename can't be empty.", nameof(filename)); - } + /// <summary> + /// Gets the cache path. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="uniqueName">Name of the unique.</param> + /// <param name="fileExtension">The file extension.</param> + /// <returns>System.String.</returns> + /// <exception cref="ArgumentNullException"> + /// path + /// or + /// uniqueName + /// or + /// fileExtension. + /// </exception> + public string GetCachePath(string path, string uniqueName, string fileExtension) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentException.ThrowIfNullOrEmpty(uniqueName); + ArgumentException.ThrowIfNullOrEmpty(fileExtension); - var prefix = filename.Slice(0, 1); + var filename = uniqueName.GetMD5() + fileExtension; - return Path.Join(path, prefix, filename); + return GetCachePath(path, filename); + } + + /// <summary> + /// Gets the cache path. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + /// <exception cref="ArgumentNullException"> + /// path + /// or + /// filename. + /// </exception> + public string GetCachePath(ReadOnlySpan<char> path, ReadOnlySpan<char> filename) + { + if (path.IsEmpty) + { + throw new ArgumentException("Path can't be empty.", nameof(path)); } - /// <inheritdoc /> - public void CreateImageCollage(ImageCollageOptions options, string? libraryName) + if (filename.IsEmpty) { - _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath); + throw new ArgumentException("Filename can't be empty.", nameof(filename)); + } + + var prefix = filename.Slice(0, 1); + + return Path.Join(path, prefix, filename); + } + + /// <inheritdoc /> + public void CreateImageCollage(ImageCollageOptions options, string? libraryName) + { + _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath); - _imageEncoder.CreateImageCollage(options, libraryName); + _imageEncoder.CreateImageCollage(options, libraryName); - _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath); + _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath); + } + + /// <inheritdoc /> + public void Dispose() + { + if (_disposed) + { + return; } - /// <inheritdoc /> - public void Dispose() + if (_imageEncoder is IDisposable disposable) { - if (_disposed) - { - return; - } + disposable.Dispose(); + } - if (_imageEncoder is IDisposable disposable) - { - disposable.Dispose(); - } + _parallelEncodingLimit?.Dispose(); - _disposed = true; - } + _disposed = true; } } diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index a5bc8eaa7..7aa994503 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -23,7 +23,7 @@ <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/src/Jellyfin.Drawing/NullImageEncoder.cs b/src/Jellyfin.Drawing/NullImageEncoder.cs index 24dda108e..171128bed 100644 --- a/src/Jellyfin.Drawing/NullImageEncoder.cs +++ b/src/Jellyfin.Drawing/NullImageEncoder.cs @@ -3,56 +3,55 @@ using System.Collections.Generic; using MediaBrowser.Controller.Drawing; using MediaBrowser.Model.Drawing; -namespace Jellyfin.Drawing +namespace Jellyfin.Drawing; + +/// <summary> +/// A fallback implementation of <see cref="IImageEncoder" />. +/// </summary> +public class NullImageEncoder : IImageEncoder { - /// <summary> - /// A fallback implementation of <see cref="IImageEncoder" />. - /// </summary> - public class NullImageEncoder : IImageEncoder - { - /// <inheritdoc /> - public IReadOnlyCollection<string> SupportedInputFormats - => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" }; + /// <inheritdoc /> + public IReadOnlyCollection<string> SupportedInputFormats + => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "png", "jpeg", "jpg" }; - /// <inheritdoc /> - public IReadOnlyCollection<ImageFormat> SupportedOutputFormats + /// <inheritdoc /> + public IReadOnlyCollection<ImageFormat> SupportedOutputFormats => new HashSet<ImageFormat>() { ImageFormat.Jpg, ImageFormat.Png }; - /// <inheritdoc /> - public string Name => "Null Image Encoder"; - - /// <inheritdoc /> - public bool SupportsImageCollageCreation => false; - - /// <inheritdoc /> - public bool SupportsImageEncoding => false; - - /// <inheritdoc /> - public ImageDimensions GetImageSize(string path) - => throw new NotImplementedException(); - - /// <inheritdoc /> - public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) - { - throw new NotImplementedException(); - } - - /// <inheritdoc /> - public void CreateImageCollage(ImageCollageOptions options, string? libraryName) - { - throw new NotImplementedException(); - } - - /// <inheritdoc /> - public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) - { - throw new NotImplementedException(); - } - - /// <inheritdoc /> - public string GetImageBlurHash(int xComp, int yComp, string path) - { - throw new NotImplementedException(); - } + /// <inheritdoc /> + public string Name => "Null Image Encoder"; + + /// <inheritdoc /> + public bool SupportsImageCollageCreation => false; + + /// <inheritdoc /> + public bool SupportsImageEncoding => false; + + /// <inheritdoc /> + public ImageDimensions GetImageSize(string path) + => throw new NotImplementedException(); + + /// <inheritdoc /> + public string EncodeImage(string inputPath, DateTime dateModified, string outputPath, bool autoOrient, ImageOrientation? orientation, int quality, ImageProcessingOptions options, ImageFormat outputFormat) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public void CreateImageCollage(ImageCollageOptions options, string? libraryName) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public string GetImageBlurHash(int xComp, int yComp, string path) + { + throw new NotImplementedException(); } } diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 9fed8cbd9..d7c05ea57 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -34,7 +34,7 @@ <!-- Code Analyzers--> <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index 32f80812a..9a025d558 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj +++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj @@ -7,7 +7,7 @@ <!-- Code Analyzers--> <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index b11bdc477..fe4e57693 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -11,7 +11,7 @@ <!-- Code Analyzers--> <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index bd412bc76..6966d81d4 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -15,21 +15,21 @@ <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.2" /> <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> + <PackageReference Include="Moq" Version="4.18.4" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index 3ca761b3d..5110d5917 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -12,19 +12,19 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> <PackageReference Include="FsCheck.Xunit" Version="2.16.5" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 650973c6a..97350feda 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -12,19 +12,19 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj index cba946800..a2ecd6083 100644 --- a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -7,19 +7,19 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj index 075bcaac8..313192b24 100644 --- a/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj +++ b/tests/Jellyfin.Extensions.Tests/Jellyfin.Extensions.Tests.csproj @@ -7,13 +7,13 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2"> + <PackageReference Include="coverlet.collector" Version="3.2.0"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> @@ -22,7 +22,7 @@ <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj b/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj index f7163edc7..22b0c417b 100644 --- a/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj @@ -7,13 +7,13 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2"> + <PackageReference Include="coverlet.collector" Version="3.2.0"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> @@ -21,7 +21,7 @@ <!-- Code Analyzers --> <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj b/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj index 72bfb3fd2..373a54504 100644 --- a/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Keyframes.Tests/Jellyfin.MediaEncoding.Keyframes.Tests.csproj @@ -8,13 +8,13 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2"> + <PackageReference Include="coverlet.collector" Version="3.2.0"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> @@ -22,7 +22,7 @@ <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index e68e7f39a..a9a0dbc22 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -21,9 +21,9 @@ <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> @@ -33,7 +33,7 @@ <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs index 243127438..9ace80bbd 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -26,7 +26,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Path = "/media/sub.ass", IsExternal = true }, - new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true)); + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + }); data.Add( new MediaSourceInfo() @@ -38,7 +44,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Path = "/media/sub.ssa", IsExternal = true }, - new SubtitleEncoder.SubtitleInfo("/media/sub.ssa", MediaProtocol.File, "ssa", true)); + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ssa", + Protocol = MediaProtocol.File, + Format = "ssa", + IsExternal = true + }); data.Add( new MediaSourceInfo() @@ -50,7 +62,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Path = "/media/sub.srt", IsExternal = true }, - new SubtitleEncoder.SubtitleInfo("/media/sub.srt", MediaProtocol.File, "srt", true)); + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.srt", + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }); data.Add( new MediaSourceInfo() @@ -62,14 +80,20 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Path = "/media/sub.ass", IsExternal = true }, - new SubtitleEncoder.SubtitleInfo("/media/sub.ass", MediaProtocol.File, "ass", true)); + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + }); return data; } [Theory] [MemberData(nameof(GetReadableFile_Valid_TestData))] - internal async Task GetReadableFile_Valid_Success(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleEncoder.SubtitleInfo subtitleInfo) + public async Task GetReadableFile_Valid_Success(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleEncoder.SubtitleInfo subtitleInfo) { var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); var subtitleEncoder = fixture.Create<SubtitleEncoder>(); diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index 2c7e393af..9858623f8 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -7,14 +7,14 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> <PackageReference Include="FsCheck.Xunit" Version="2.16.5" /> </ItemGroup> @@ -26,7 +26,7 @@ <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 0d9acf0e1..920f490ed 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -12,14 +12,14 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> </ItemGroup> <ItemGroup> @@ -28,7 +28,7 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj index 9e13dd4ad..74bf7cb0e 100644 --- a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -12,20 +12,20 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> <PackageReference Include="FsCheck.Xunit" Version="2.16.5" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Moq" Version="4.18.4" /> </ItemGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj index 2d8e3c8f2..d3292c38e 100644 --- a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -13,14 +13,14 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2"> + <PackageReference Include="coverlet.collector" Version="3.2.0"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> @@ -28,7 +28,7 @@ <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index d91b4f00b..b796e07d1 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -21,20 +21,20 @@ <ItemGroup> <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Xunit.SkippableFact" Version="1.4.13" /> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj index ecc3ebb86..c40f6942b 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj +++ b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj @@ -9,17 +9,17 @@ <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.2" /> <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Xunit.Priority" Version="1.1.6" /> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> + <PackageReference Include="Moq" Version="4.18.4" /> </ItemGroup> <ItemGroup> @@ -31,7 +31,7 @@ <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index c38faeda1..55bc43455 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -4,12 +4,16 @@ using System.Globalization; using System.IO; using System.Threading; using Emby.Server.Implementations; +using Jellyfin.Server.Extensions; +using Jellyfin.Server.Helpers; using MediaBrowser.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Extensions.Logging; @@ -32,7 +36,7 @@ namespace Jellyfin.Server.Integration.Tests Log.Logger = new LoggerConfiguration() .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) .CreateLogger(); - Program.PerformStaticInitialization(); + StartupHelpers.PerformStaticInitialization(); } /// <inheritdoc/> @@ -62,7 +66,7 @@ namespace Jellyfin.Server.Integration.Tests // Create the logging config file // TODO: We shouldn't need to do this since we are only logging to console - Program.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult(); + StartupHelpers.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult(); // Create a copy of the application configuration to use for startup var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths); @@ -78,11 +82,17 @@ namespace Jellyfin.Server.Integration.Tests commandLineOpts, startupConfig); _disposableComponents.Add(appHost); - var serviceCollection = new ServiceCollection(); - appHost.Init(serviceCollection); - // Configure the web host builder - Program.ConfigureWebHostBuilder(builder, appHost, serviceCollection, commandLineOpts, startupConfig, appPaths); + builder.ConfigureServices(services => appHost.Init(services)) + .ConfigureWebHostBuilder(appHost, startupConfig, appPaths, NullLogger.Instance) + .ConfigureAppConfiguration((context, builder) => + { + builder + .SetBasePath(appPaths.ConfigurationDirectoryPath) + .AddInMemoryCollection(ConfigurationOptions.DefaultConfiguration) + .AddEnvironmentVariables("JELLYFIN_") + .AddInMemoryCollection(commandLineOpts.ConvertToConfig()); + }); } /// <inheritdoc/> diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj index 0ce2721c7..a72a6f185 100644 --- a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj +++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj @@ -10,21 +10,21 @@ <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.17.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.2" /> <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> + <PackageReference Include="Moq" Version="4.18.4" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> diff --git a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs index d15c9d6f5..797fc8f64 100644 --- a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs +++ b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Linq; -using Jellyfin.Server.Middleware; +using Jellyfin.Api.Middleware; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj index bde34d639..dc5b5b9e6 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj +++ b/tests/Jellyfin.XbmcMetadata.Tests/Jellyfin.XbmcMetadata.Tests.csproj @@ -13,19 +13,19 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" /> - <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> + <PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="coverlet.collector" Version="3.1.2" /> + <PackageReference Include="coverlet.collector" Version="3.2.0" /> </ItemGroup> <!-- Code Analyzers --> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3"> + <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> |
