Browse Source

Updated to latest unstable.

BaronGreenback 4 years ago
parent
commit
889e988167
56 changed files with 828 additions and 191 deletions
  1. 6 0
      .ci/azure-pipelines-package.yml
  2. 1 3
      Emby.Dlna/Emby.Dlna.csproj
  3. 6 0
      Emby.Dlna/Main/DlnaEntryPoint.cs
  4. 2 2
      Emby.Dlna/Profiles/SonyPs3Profile.cs
  5. 2 2
      Emby.Dlna/Profiles/SonyPs4Profile.cs
  6. 2 2
      Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml
  7. 2 2
      Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml
  8. 0 8
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  9. 1 1
      Emby.Server.Implementations/HttpServer/Security/SessionContext.cs
  10. 6 6
      Emby.Server.Implementations/Library/UserViewManager.cs
  11. 7 8
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  12. 4 1
      Emby.Server.Implementations/Localization/Core/en-GB.json
  13. 5 1
      Emby.Server.Implementations/Localization/Core/es_419.json
  14. 5 2
      Emby.Server.Implementations/Localization/Core/ja.json
  15. 18 1
      Emby.Server.Implementations/Localization/Core/kk.json
  16. 1 1
      Emby.Server.Implementations/Localization/Core/nl.json
  17. 2 2
      Emby.Server.Implementations/Localization/Core/ru.json
  18. 10 7
      Emby.Server.Implementations/Localization/Core/sr.json
  19. 1 2
      Emby.Server.Implementations/TV/TVSeriesManager.cs
  20. 1 0
      Jellyfin.Api/Controllers/DisplayPreferencesController.cs
  21. 106 15
      Jellyfin.Api/Controllers/DlnaServerController.cs
  22. 6 2
      Jellyfin.Api/Controllers/ImageController.cs
  23. 8 8
      Jellyfin.Api/Controllers/ItemsController.cs
  24. 1 3
      Jellyfin.Api/Controllers/LibraryController.cs
  25. 3 8
      Jellyfin.Api/Controllers/LiveTvController.cs
  26. 14 14
      Jellyfin.Api/Controllers/MediaInfoController.cs
  27. 3 2
      Jellyfin.Api/Controllers/SystemController.cs
  28. 0 2
      Jellyfin.Api/Jellyfin.Api.csproj
  29. 28 0
      Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs
  30. 55 0
      Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs
  31. 53 3
      Jellyfin.Data/Entities/User.cs
  32. 8 10
      Jellyfin.Server.Implementations/Users/UserManager.cs
  33. 12 3
      Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
  34. 2 2
      Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
  35. 34 0
      MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs
  36. 47 32
      MediaBrowser.Common/Json/JsonDefaults.cs
  37. 1 1
      MediaBrowser.Common/MediaBrowser.Common.csproj
  38. 4 4
      MediaBrowser.Controller/Channels/Channel.cs
  39. 1 1
      MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs
  40. 1 1
      MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
  41. 4 4
      MediaBrowser.Controller/Entities/BaseItem.cs
  42. 3 7
      MediaBrowser.Controller/Entities/Folder.cs
  43. 1 1
      MediaBrowser.Controller/Entities/Movies/BoxSet.cs
  44. 1 1
      MediaBrowser.Controller/Entities/TV/Series.cs
  45. 30 11
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  46. 1 1
      MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
  47. 1 1
      MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
  48. 0 1
      MediaBrowser.Model/MediaBrowser.Model.csproj
  49. 0 1
      MediaBrowser.Providers/MediaBrowser.Providers.csproj
  50. 132 1
      MediaBrowser.Providers/TV/SeriesMetadataService.cs
  51. 31 0
      deployment/Dockerfile.linux.amd64-musl
  52. 31 0
      deployment/Dockerfile.linux.arm64
  53. 31 0
      deployment/Dockerfile.linux.armhf
  54. 31 0
      deployment/build.linux.amd64-musl
  55. 31 0
      deployment/build.linux.arm64
  56. 31 0
      deployment/build.linux.armhf

+ 6 - 0
.ci/azure-pipelines-package.yml

@@ -22,6 +22,12 @@ jobs:
         BuildConfiguration: ubuntu.armhf
       Linux.amd64:
         BuildConfiguration: linux.amd64
+      Linux.amd64-musl:
+        BuildConfiguration: linux.amd64-musl
+      Linux.arm64:
+        BuildConfiguration: linux.arm64
+      Linux.armhf:
+        BuildConfiguration: linux.armhf
       Windows.amd64:
         BuildConfiguration: windows.amd64
       MacOS:

+ 1 - 3
Emby.Dlna/Emby.Dlna.csproj

@@ -78,9 +78,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
-    <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="2.2.0" />
-    <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" />
+    <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
   </ItemGroup>
 
 </Project>

+ 6 - 0
Emby.Dlna/Main/DlnaEntryPoint.cs

@@ -126,6 +126,11 @@ namespace Emby.Dlna.Main
 
         public static DlnaEntryPoint Current { get; private set; }
 
+        /// <summary>
+        /// Gets a value indicating whether the dlna server is enabled.
+        /// </summary>
+        public static bool Enabled { get; private set; }
+
         public IContentDirectory ContentDirectory { get; private set; }
 
         public IConnectionManager ConnectionManager { get; private set; }
@@ -152,6 +157,7 @@ namespace Emby.Dlna.Main
         private void ReloadComponents()
         {
             var options = _config.GetDlnaConfiguration();
+            Enabled = options.EnableServer;
 
             StartSsdpHandler();
 

+ 2 - 2
Emby.Dlna/Profiles/SonyPs3Profile.cs

@@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles
                     Container = "ts,mpegts",
                     Type = DlnaProfileType.Video,
                     VideoCodec = "mpeg1video,mpeg2video,h264",
-                    AudioCodec = "ac3,mp2,mp3,aac"
+                    AudioCodec = "aac,ac3,mp2"
                 },
                 new DirectPlayProfile
                 {
@@ -92,7 +92,7 @@ namespace Emby.Dlna.Profiles
                 {
                     Container = "ts",
                     VideoCodec = "h264",
-                    AudioCodec = "ac3,aac,mp3",
+                    AudioCodec = "aac,ac3,mp2",
                     Type = DlnaProfileType.Video
                 },
                 new TranscodingProfile

+ 2 - 2
Emby.Dlna/Profiles/SonyPs4Profile.cs

@@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles
                     Container = "ts,mpegts",
                     Type = DlnaProfileType.Video,
                     VideoCodec = "mpeg1video,mpeg2video,h264",
-                    AudioCodec = "ac3,mp2,mp3,aac"
+                    AudioCodec = "aac,ac3,mp2"
                 },
                 new DirectPlayProfile
                 {
@@ -94,7 +94,7 @@ namespace Emby.Dlna.Profiles
                 {
                     Container = "ts",
                     VideoCodec = "h264",
-                    AudioCodec = "mp3",
+                    AudioCodec = "aac,ac3,mp2",
                     Type = DlnaProfileType.Video
                 },
                 new TranscodingProfile

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml

@@ -38,7 +38,7 @@
   <XmlRootAttributes />
   <DirectPlayProfiles>
     <DirectPlayProfile container="avi" audioCodec="mp2,mp3" videoCodec="mpeg4" type="Video" />
-    <DirectPlayProfile container="ts,mpegts" audioCodec="ac3,mp2,mp3,aac" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
+    <DirectPlayProfile container="ts,mpegts" audioCodec="aac,ac3,mp2" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
     <DirectPlayProfile container="mpeg" audioCodec="mp2" videoCodec="mpeg1video,mpeg2video" type="Video" />
     <DirectPlayProfile container="mp4" audioCodec="aac,ac3" videoCodec="h264,mpeg4" type="Video" />
     <DirectPlayProfile container="aac,mp3,wav" type="Audio" />
@@ -46,7 +46,7 @@
   </DirectPlayProfiles>
   <TranscodingProfiles>
     <TranscodingProfile container="mp3" type="Audio" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
-    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="ac3,aac,mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
+    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="aac,ac3,mp2" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
     <TranscodingProfile container="jpeg" type="Photo" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
   </TranscodingProfiles>
   <ContainerProfiles>

+ 2 - 2
Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml

@@ -38,7 +38,7 @@
   <XmlRootAttributes />
   <DirectPlayProfiles>
     <DirectPlayProfile container="avi" audioCodec="mp2,mp3" videoCodec="mpeg4" type="Video" />
-    <DirectPlayProfile container="ts,mpegts" audioCodec="ac3,mp2,mp3,aac" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
+    <DirectPlayProfile container="ts,mpegts" audioCodec="aac,ac3,mp2" videoCodec="mpeg1video,mpeg2video,h264" type="Video" />
     <DirectPlayProfile container="mpeg" audioCodec="mp2" videoCodec="mpeg1video,mpeg2video" type="Video" />
     <DirectPlayProfile container="mp4,mkv,m4v" audioCodec="aac,ac3" videoCodec="h264,mpeg4" type="Video" />
     <DirectPlayProfile container="aac,mp3,wav" type="Audio" />
@@ -46,7 +46,7 @@
   </DirectPlayProfiles>
   <TranscodingProfiles>
     <TranscodingProfile container="mp3" type="Audio" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Bytes" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
-    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
+    <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="aac,ac3,mp2" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
     <TranscodingProfile container="jpeg" type="Photo" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" />
   </TranscodingProfiles>
   <ContainerProfiles>

+ 0 - 8
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -23,14 +23,6 @@
 
   <ItemGroup>
     <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" />
-    <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" />
-    <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
-    <PackageReference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" Version="2.2.0" />
-    <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
-    <PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
-    <PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" />
-    <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
-    <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" />
     <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
     <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />

+ 1 - 1
Emby.Server.Implementations/HttpServer/Security/SessionContext.cs

@@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
 
         public User GetUser(object requestContext)
         {
-            return GetUser((HttpContext)requestContext);
+            return GetUser(((HttpRequest)requestContext).HttpContext);
         }
     }
 }

+ 6 - 6
Emby.Server.Implementations/Library/UserViewManager.cs

@@ -129,23 +129,23 @@ namespace Emby.Server.Implementations.Library
 
             if (!query.IncludeHidden)
             {
-                list = list.Where(i => !user.GetPreference(PreferenceKind.MyMediaExcludes).Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))).ToList();
+                list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList();
             }
 
             var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList();
 
-            var orders = user.GetPreference(PreferenceKind.OrderedViews).ToList();
+            var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews);
 
             return list
                 .OrderBy(i =>
                 {
-                    var index = orders.IndexOf(i.Id.ToString("D", CultureInfo.InvariantCulture));
+                    var index = Array.IndexOf(orders, i.Id);
 
                     if (index == -1
                         && i is UserView view
                         && view.DisplayParentId != Guid.Empty)
                     {
-                        index = orders.IndexOf(view.DisplayParentId.ToString("D", CultureInfo.InvariantCulture));
+                        index = Array.IndexOf(orders, view.DisplayParentId);
                     }
 
                     return index == -1 ? int.MaxValue : index;
@@ -280,8 +280,8 @@ namespace Emby.Server.Implementations.Library
             {
                 parents = _libraryManager.GetUserRootFolder().GetChildren(user, true)
                     .Where(i => i is Folder)
-                    .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes)
-                        .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
+                    .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes)
+                        .Contains(i.Id))
                     .ToList();
             }
 

+ 7 - 8
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -784,18 +784,17 @@ namespace Emby.Server.Implementations.LiveTv.Listings
             var allStations = root.stations ?? new List<ScheduleDirect.Station>();
 
             var map = root.map;
-            int len = map.Count;
-            var array = new List<ChannelInfo>(len);
-            for (int i = 0; i < len; i++)
+            var list = new List<ChannelInfo>(map.Count);
+            foreach (var channel in map)
             {
-                var channelNumber = GetChannelNumber(map[i]);
+                var channelNumber = GetChannelNumber(channel);
 
-                var station = allStations.Find(item => string.Equals(item.stationID, map[i].stationID, StringComparison.OrdinalIgnoreCase));
+                var station = allStations.Find(item => string.Equals(item.stationID, channel.stationID, StringComparison.OrdinalIgnoreCase));
                 if (station == null)
                 {
                     station = new ScheduleDirect.Station
                     {
-                        stationID = map[i].stationID
+                        stationID = channel.stationID
                     };
                 }
 
@@ -812,10 +811,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings
                     channelInfo.ImageUrl = station.logo.URL;
                 }
 
-                array[i] = channelInfo;
+                list.Add(channelInfo);
             }
 
-            return array;
+            return list;
         }
 
         private static string NormalizeName(string value)

+ 4 - 1
Emby.Server.Implementations/Localization/Core/en-GB.json

@@ -115,5 +115,8 @@
     "TasksLibraryCategory": "Library",
     "TasksMaintenanceCategory": "Maintenance",
     "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.",
-    "TaskCleanActivityLog": "Clean Activity Log"
+    "TaskCleanActivityLog": "Clean Activity Log",
+    "Undefined": "Undefined",
+    "Forced": "Forced",
+    "Default": "Default"
 }

+ 5 - 1
Emby.Server.Implementations/Localization/Core/es_419.json

@@ -112,5 +112,9 @@
     "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}",
     "AuthenticationSucceededWithUserName": "{0} autenticado con éxito",
     "Application": "Aplicación",
-    "AppDeviceValues": "App: {0}, Dispositivo: {1}"
+    "AppDeviceValues": "App: {0}, Dispositivo: {1}",
+    "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.",
+    "TaskCleanActivityLog": "Limpiar Registro de Actividades",
+    "Undefined": "Sin definir",
+    "Forced": "Forzado"
 }

+ 5 - 2
Emby.Server.Implementations/Localization/Core/ja.json

@@ -4,7 +4,7 @@
     "Application": "アプリケーション",
     "Artists": "アーティスト",
     "AuthenticationSucceededWithUserName": "{0} 認証に成功しました",
-    "Books": "ブック",
+    "Books": "ブック",
     "CameraImageUploadedFrom": "新しいカメライメージが {0}からアップロードされました",
     "Channels": "チャンネル",
     "ChapterNameValue": "チャプター {0}",
@@ -114,5 +114,8 @@
     "TaskRefreshChapterImages": "チャプター画像を抽出する",
     "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする",
     "TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。",
-    "TaskCleanActivityLog": "アクティビティの履歴を消去"
+    "TaskCleanActivityLog": "アクティビティの履歴を消去",
+    "Undefined": "未定義",
+    "Forced": "強制",
+    "Default": "デフォルト"
 }

+ 18 - 1
Emby.Server.Implementations/Localization/Core/kk.json

@@ -91,5 +91,22 @@
     "UserStoppedPlayingItemWithValues": "{0} - {1} oınatýyn {2} toqtatty",
     "ValueHasBeenAddedToLibrary": "{0} (tasyǵyshhanaǵa ústelindi)",
     "ValueSpecialEpisodeName": "Arnaıy - {0}",
-    "VersionNumber": "Nusqasy {0}"
+    "VersionNumber": "Nusqasy {0}",
+    "Default": "Ádepki",
+    "TaskDownloadMissingSubtitles": "Joq sýbtıtrlerdi júktep alý",
+    "TaskRefreshChannels": "Arnalardy jańartý",
+    "TaskCleanTranscode": "Qaıta kodtaý katalogyn tazalaý",
+    "TaskUpdatePlugins": "Plagınderdi jańartý",
+    "TaskRefreshPeople": "Adamdardy jańartý",
+    "TaskCleanLogs": "Jurnal katalogyn tazalaý",
+    "TaskRefreshLibrary": "Tasyǵyshhanany skanerleý",
+    "TaskRefreshChapterImages": "Sahna keskinderin shyǵaryp alý",
+    "TaskCleanCache": "Kesh katalogyn tazalaý",
+    "TaskCleanActivityLog": "Áreket jurnalyn tazalaý",
+    "TasksChannelsCategory": "Internet-arnalar",
+    "TasksApplicationCategory": "Qoldanba",
+    "TasksLibraryCategory": "Tasyǵyshhana",
+    "TasksMaintenanceCategory": "Qyzmet kórsetý",
+    "Undefined": "Anyqtalmady",
+    "Forced": "Májbúrli"
 }

+ 1 - 1
Emby.Server.Implementations/Localization/Core/nl.json

@@ -1,7 +1,7 @@
 {
     "Albums": "Albums",
     "AppDeviceValues": "App: {0}, Apparaat: {1}",
-    "Application": "Programma",
+    "Application": "Applicatie",
     "Artists": "Artiesten",
     "AuthenticationSucceededWithUserName": "{0} is succesvol geverifieerd",
     "Books": "Boeken",

+ 2 - 2
Emby.Server.Implementations/Localization/Core/ru.json

@@ -96,7 +96,7 @@
     "TaskRefreshChannels": "Обновление каналов",
     "TaskCleanTranscode": "Очистка каталога перекодировки",
     "TaskUpdatePlugins": "Обновление плагинов",
-    "TaskRefreshPeople": "Обновление метаданных людей",
+    "TaskRefreshPeople": "Подновить людей",
     "TaskCleanLogs": "Очистка каталога журналов",
     "TaskRefreshLibrary": "Сканирование медиатеки",
     "TaskRefreshChapterImages": "Извлечение изображений сцен",
@@ -109,7 +109,7 @@
     "TaskRefreshChannelsDescription": "Обновляются данные интернет-каналов.",
     "TaskCleanTranscodeDescription": "Удаляются файлы перекодировки старше одного дня.",
     "TaskUpdatePluginsDescription": "Загружаются и устанавливаются обновления для плагинов, у которых включено автоматическое обновление.",
-    "TaskRefreshPeopleDescription": "Обновляются метаданные актеров и режиссёров в медиатеке.",
+    "TaskRefreshPeopleDescription": "Обновляются метаданные для актёров и режиссёров в медиатеке.",
     "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).",
     "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.",
     "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.",

+ 10 - 7
Emby.Server.Implementations/Localization/Core/sr.json

@@ -66,7 +66,7 @@
     "Inherit": "Наследи",
     "HomeVideos": "Кућни видео",
     "HeaderRecordingGroups": "Групе снимања",
-    "HeaderNextUp": "Следеће горе",
+    "HeaderNextUp": "Следи",
     "HeaderLiveTV": "ТВ уживо",
     "HeaderFavoriteSongs": "Омиљене песме",
     "HeaderFavoriteShows": "Омиљене серије",
@@ -79,17 +79,17 @@
     "Folders": "Фасцикле",
     "Favorites": "Омиљено",
     "FailedLoginAttemptWithUserName": "Неуспела пријава са {0}",
-    "DeviceOnlineWithName": "{0} се повезао",
+    "DeviceOnlineWithName": "{0} је повезан",
     "DeviceOfflineWithName": "{0} је прекинуо везу",
     "Collections": "Колекције",
     "ChapterNameValue": "Поглавље {0}",
     "Channels": "Канали",
-    "CameraImageUploadedFrom": "Нова фотографија је послата са {0}",
+    "CameraImageUploadedFrom": "Нова фотографија је учитана са {0}",
     "Books": "Књиге",
     "AuthenticationSucceededWithUserName": "{0} успешно проверено",
-    "Artists": "Извођач",
+    "Artists": "Извођачи",
     "Application": "Апликација",
-    "AppDeviceValues": "Апл: {0}, уређај: {1}",
+    "AppDeviceValues": "Апликација: {0}, Уређај: {1}",
     "Albums": "Албуми",
     "TaskDownloadMissingSubtitlesDescription": "Претражује интернет за недостајуће титлове на основу конфигурације метаподатака.",
     "TaskDownloadMissingSubtitles": "Преузмите недостајуће титлове",
@@ -104,7 +104,7 @@
     "TaskCleanLogsDescription": "Брише логове старије од {0} дана.",
     "TaskCleanLogs": "Очистите директоријум логова",
     "TaskRefreshLibraryDescription": "Скенира вашу медијску библиотеку за нове датотеке и освежава метаподатке.",
-    "TaskRefreshLibrary": "Скенирај Библиотеку Медија",
+    "TaskRefreshLibrary": "Скенирај библиотеку медија",
     "TaskRefreshChapterImagesDescription": "Ствара сличице за видео записе који имају поглавља.",
     "TaskRefreshChapterImages": "Издвоји слике из поглавља",
     "TaskCleanCacheDescription": "Брише Кеш фајлове који више нису потребни систему.",
@@ -114,5 +114,8 @@
     "TasksLibraryCategory": "Библиотека",
     "TasksMaintenanceCategory": "Одржавање",
     "TaskCleanActivityLogDescription": "Брише историју активности старију од конфигурисаног броја година.",
-    "TaskCleanActivityLog": "Очисти историју активности"
+    "TaskCleanActivityLog": "Очисти историју активности",
+    "Undefined": "Недефинисано",
+    "Forced": "Форсирано",
+    "Default": "Подразумевано"
 }

+ 1 - 2
Emby.Server.Implementations/TV/TVSeriesManager.cs

@@ -75,8 +75,7 @@ namespace Emby.Server.Implementations.TV
             {
                 parents = _libraryManager.GetUserRootFolder().GetChildren(user, true)
                    .Where(i => i is Folder)
-                   .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes)
-                       .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
+                   .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes).Contains(i.Id))
                    .ToArray();
             }
 

+ 1 - 0
Jellyfin.Api/Controllers/DisplayPreferencesController.cs

@@ -85,6 +85,7 @@ namespace Jellyfin.Api.Controllers
             dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
             dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
+            dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme;
 
             // Load all custom display preferences
             var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);

+ 106 - 15
Jellyfin.Api/Controllers/DlnaServerController.cs

@@ -41,18 +41,25 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Description xml returned.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
         [HttpGet("{serverId}/description")]
         [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult GetDescriptionXml([FromRoute, Required] string serverId)
         {
-            var url = GetAbsoluteUri();
-            var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
-            var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress);
-            return Ok(xml);
+            if (DlnaEntryPoint.Enabled)
+            {
+                var url = GetAbsoluteUri();
+                var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
+                var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress);
+                return Ok(xml);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -60,17 +67,24 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Dlna content directory returned.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
         [HttpGet("{serverId}/ContentDirectory")]
         [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")]
         [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         public ActionResult GetContentDirectory([FromRoute, Required] string serverId)
         {
-            return Ok(_contentDirectory.GetServiceXml());
+            if (DlnaEntryPoint.Enabled)
+            {
+                return Ok(_contentDirectory.GetServiceXml());
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -78,17 +92,24 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Dlna media receiver registrar xml returned.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/MediaReceiverRegistrar")]
         [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
         [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         public ActionResult GetMediaReceiverRegistrar([FromRoute, Required] string serverId)
         {
-            return Ok(_mediaReceiverRegistrar.GetServiceXml());
+            if (DlnaEntryPoint.Enabled)
+            {
+                return Ok(_mediaReceiverRegistrar.GetServiceXml());
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -96,17 +117,24 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Dlna media receiver registrar xml returned.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Dlna media receiver registrar xml.</returns>
         [HttpGet("{serverId}/ConnectionManager")]
         [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
         [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         public ActionResult GetConnectionManager([FromRoute, Required] string serverId)
         {
-            return Ok(_connectionManager.GetServiceXml());
+            if (DlnaEntryPoint.Enabled)
+            {
+                return Ok(_connectionManager.GetServiceXml());
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -114,14 +142,21 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Request processed.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/ContentDirectory/Control")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
         {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -129,14 +164,21 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Request processed.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/ConnectionManager/Control")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
         {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -144,14 +186,21 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Request processed.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Control response.</returns>
         [HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
         {
-            return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -159,17 +208,24 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Request processed.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
         {
-            return ProcessEventRequest(_mediaReceiverRegistrar);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return ProcessEventRequest(_mediaReceiverRegistrar);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -177,17 +233,24 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Request processed.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ContentDirectory/Events")]
         [HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
         {
-            return ProcessEventRequest(_contentDirectory);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return ProcessEventRequest(_contentDirectory);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -195,17 +258,24 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <response code="200">Request processed.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Event subscription response.</returns>
         [HttpSubscribe("{serverId}/ConnectionManager/Events")]
         [HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
         [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [Produces(MediaTypeNames.Text.Xml)]
         [ProducesFile(MediaTypeNames.Text.Xml)]
         public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
         {
-            return ProcessEventRequest(_connectionManager);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return ProcessEventRequest(_connectionManager);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -213,14 +283,24 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="serverId">Server UUID.</param>
         /// <param name="fileName">The icon filename.</param>
+        /// <response code="200">Request processed.</response>
+        /// <response code="404">Not Found.</response>
+        /// <response code="503">DLNA is disabled.</response>
         /// <returns>Icon stream.</returns>
         [HttpGet("{serverId}/icons/{fileName}")]
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [ProducesImageFile]
         public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName)
         {
-            return GetIconInternal(fileName);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return GetIconInternal(fileName);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         /// <summary>
@@ -228,11 +308,22 @@ namespace Jellyfin.Api.Controllers
         /// </summary>
         /// <param name="fileName">The icon filename.</param>
         /// <returns>Icon stream.</returns>
+        /// <response code="200">Request processed.</response>
+        /// <response code="404">Not Found.</response>
+        /// <response code="503">DLNA is disabled.</response>
         [HttpGet("icons/{fileName}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
         [ProducesImageFile]
         public ActionResult GetIcon([FromRoute, Required] string fileName)
         {
-            return GetIconInternal(fileName);
+            if (DlnaEntryPoint.Enabled)
+            {
+                return GetIconInternal(fileName);
+            }
+
+            return StatusCode(StatusCodes.Status503ServiceUnavailable);
         }
 
         private ActionResult GetIconInternal(string fileName)

+ 6 - 2
Jellyfin.Api/Controllers/ImageController.cs

@@ -325,9 +325,11 @@ namespace Jellyfin.Api.Controllers
                 return NotFound();
             }
 
+            await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+
             // Handle image/png; charset=utf-8
             var mimeType = Request.ContentType.Split(';').FirstOrDefault();
-            await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
             await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
             return NoContent();
@@ -358,9 +360,11 @@ namespace Jellyfin.Api.Controllers
                 return NotFound();
             }
 
+            await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+
             // Handle image/png; charset=utf-8
             var mimeType = Request.ContentType.Split(';').FirstOrDefault();
-            await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+            await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
             await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
 
             return NoContent();

+ 8 - 8
Jellyfin.Api/Controllers/ItemsController.cs

@@ -254,18 +254,18 @@ namespace Jellyfin.Api.Controllers
                 includeItemTypes = new[] { "Playlist" };
             }
 
-            bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
+            var enabledChannels = user!.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels);
+
+            bool isInEnabledFolder = Array.IndexOf(user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders), item.Id) != -1
                                      // Assume all folders inside an EnabledChannel are enabled
-                                     || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id)
+                                     || Array.IndexOf(enabledChannels, item.Id) != -1
                                      // Assume all items inside an EnabledChannel are enabled
-                                     || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.ChannelId);
+                                     || Array.IndexOf(enabledChannels, item.ChannelId) != -1;
 
             var collectionFolders = _libraryManager.GetCollectionFolders(item);
             foreach (var collectionFolder in collectionFolders)
             {
-                if (user.GetPreference(PreferenceKind.EnabledFolders).Contains(
-                    collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
-                    StringComparer.OrdinalIgnoreCase))
+                if (user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(collectionFolder.Id))
                 {
                     isInEnabledFolder = true;
                 }
@@ -786,12 +786,12 @@ namespace Jellyfin.Api.Controllers
 
             var ancestorIds = Array.Empty<Guid>();
 
-            var excludeFolderIds = user.GetPreference(PreferenceKind.LatestItemExcludes);
+            var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes);
             if (parentIdGuid.Equals(Guid.Empty) && excludeFolderIds.Length > 0)
             {
                 ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true)
                     .Where(i => i is Folder)
-                    .Where(i => !excludeFolderIds.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
+                    .Where(i => !excludeFolderIds.Contains(i.Id))
                     .Select(i => i.Id)
                     .ToArray();
             }

+ 1 - 3
Jellyfin.Api/Controllers/LibraryController.cs

@@ -667,7 +667,7 @@ namespace Jellyfin.Api.Controllers
             }
 
             // TODO determine non-ASCII validity.
-            return PhysicalFile(path, MimeTypes.GetMimeType(path));
+            return PhysicalFile(path, MimeTypes.GetMimeType(path), filename);
         }
 
         /// <summary>
@@ -742,8 +742,6 @@ namespace Jellyfin.Api.Controllers
             {
                 Limit = limit,
                 IncludeItemTypes = includeItemTypes.ToArray(),
-                IsMovie = isMovie,
-                IsSeries = isSeries,
                 SimilarTo = item,
                 DtoOptions = dtoOptions,
                 EnableTotalRecordCount = !isMovie ?? true,

+ 3 - 8
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -1119,20 +1119,15 @@ namespace Jellyfin.Api.Controllers
         /// <summary>
         /// Set channel mappings.
         /// </summary>
-        /// <param name="providerId">Provider id.</param>
-        /// <param name="tunerChannelId">Tuner channel id.</param>
-        /// <param name="providerChannelId">Provider channel id.</param>
+        /// <param name="setChannelMappingDto">The set channel mapping dto.</param>
         /// <response code="200">Created channel mapping returned.</response>
         /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
         [HttpPost("ChannelMappings")]
         [Authorize(Policy = Policies.DefaultAuthorization)]
         [ProducesResponseType(StatusCodes.Status200OK)]
-        public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping(
-            [FromQuery] string? providerId,
-            [FromQuery] string? tunerChannelId,
-            [FromQuery] string? providerChannelId)
+        public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto)
         {
-            return await _liveTvManager.SetChannelMapping(providerId, tunerChannelId, providerChannelId).ConfigureAwait(false);
+            return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false);
         }
 
         /// <summary>

+ 14 - 14
Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -259,24 +259,24 @@ namespace Jellyfin.Api.Controllers
             [FromQuery] int? subtitleStreamIndex,
             [FromQuery] int? maxAudioChannels,
             [FromQuery] Guid? itemId,
-            [FromBody] OpenLiveStreamDto openLiveStreamDto,
-            [FromQuery] bool enableDirectPlay = true,
-            [FromQuery] bool enableDirectStream = true)
+            [FromBody] OpenLiveStreamDto? openLiveStreamDto,
+            [FromQuery] bool? enableDirectPlay,
+            [FromQuery] bool? enableDirectStream)
         {
             var request = new LiveStreamRequest
             {
-                OpenToken = openToken,
-                UserId = userId ?? Guid.Empty,
-                PlaySessionId = playSessionId,
-                MaxStreamingBitrate = maxStreamingBitrate,
-                StartTimeTicks = startTimeTicks,
-                AudioStreamIndex = audioStreamIndex,
-                SubtitleStreamIndex = subtitleStreamIndex,
-                MaxAudioChannels = maxAudioChannels,
-                ItemId = itemId ?? Guid.Empty,
+                OpenToken = openToken ?? openLiveStreamDto?.OpenToken,
+                UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty,
+                PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId,
+                MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate,
+                StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks,
+                AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex,
+                MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels,
+                ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty,
                 DeviceProfile = openLiveStreamDto?.DeviceProfile,
-                EnableDirectPlay = enableDirectPlay,
-                EnableDirectStream = enableDirectStream,
+                EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true,
+                EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true,
                 DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http }
             };
             return await _mediaInfoHelper.OpenMediaSource(Request, request).ConfigureAwait(false);

+ 3 - 2
Jellyfin.Api/Controllers/SystemController.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.IO;
 using System.Linq;
+using System.Net;
 using System.Net.Mime;
 using System.Threading;
 using System.Threading.Tasks;
@@ -66,7 +67,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<SystemInfo> GetSystemInfo()
         {
-            return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress);
+            return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback);
         }
 
         /// <summary>
@@ -78,7 +79,7 @@ namespace Jellyfin.Api.Controllers
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<PublicSystemInfo> GetPublicSystemInfo()
         {
-            return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress);
+            return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback);
         }
 
         /// <summary>

+ 0 - 2
Jellyfin.Api/Jellyfin.Api.csproj

@@ -15,9 +15,7 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
     <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.1" />
-    <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
     <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
     <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
     <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="5.6.3" />

+ 28 - 0
Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs

@@ -0,0 +1,28 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Jellyfin.Api.Models.LiveTvDtos
+{
+    /// <summary>
+    /// Set channel mapping dto.
+    /// </summary>
+    public class SetChannelMappingDto
+    {
+        /// <summary>
+        /// Gets or sets the provider id.
+        /// </summary>
+        [Required]
+        public string ProviderId { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the tuner channel id.
+        /// </summary>
+        [Required]
+        public string TunerChannelId { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Gets or sets the provider channel id.
+        /// </summary>
+        [Required]
+        public string ProviderChannelId { get; set; } = string.Empty;
+    }
+}

+ 55 - 0
Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs

@@ -10,6 +10,61 @@ namespace Jellyfin.Api.Models.MediaInfoDtos
     /// </summary>
     public class OpenLiveStreamDto
     {
+        /// <summary>
+        /// Gets or sets the open token.
+        /// </summary>
+        public string? OpenToken { get; set; }
+
+        /// <summary>
+        /// Gets or sets the user id.
+        /// </summary>
+        public Guid? UserId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the play session id.
+        /// </summary>
+        public string? PlaySessionId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the max streaming bitrate.
+        /// </summary>
+        public int? MaxStreamingBitrate { get; set; }
+
+        /// <summary>
+        /// Gets or sets the start time in ticks.
+        /// </summary>
+        public long? StartTimeTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the audio stream index.
+        /// </summary>
+        public int? AudioStreamIndex { get; set; }
+
+        /// <summary>
+        /// Gets or sets the subtitle stream index.
+        /// </summary>
+        public int? SubtitleStreamIndex { get; set; }
+
+        /// <summary>
+        /// Gets or sets the max audio channels.
+        /// </summary>
+        public int? MaxAudioChannels { get; set; }
+
+        /// <summary>
+        /// Gets or sets the item id.
+        /// </summary>
+        public Guid? ItemId { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to enable direct play.
+        /// </summary>
+        public bool? EnableDirectPlay { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether to enale direct stream.
+        /// </summary>
+        public bool? EnableDirectStream { get; set; }
+
         /// <summary>
         /// Gets or sets the device profile.
         /// </summary>

+ 53 - 3
Jellyfin.Data/Entities/User.cs

@@ -2,9 +2,9 @@
 
 using System;
 using System.Collections.Generic;
+using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations.Schema;
-using System.Globalization;
 using System.Linq;
 using System.Text.Json.Serialization;
 using Jellyfin.Data.Enums;
@@ -413,6 +413,44 @@ namespace Jellyfin.Data.Entities
             return Equals(val, string.Empty) ? Array.Empty<string>() : val.Split(Delimiter);
         }
 
+        /// <summary>
+        /// Gets the user's preferences for the given preference kind.
+        /// </summary>
+        /// <param name="preference">The preference kind.</param>
+        /// <typeparam name="T">Type of preference.</typeparam>
+        /// <returns>A {T} array containing the user's preference.</returns>
+        public T[] GetPreferenceValues<T>(PreferenceKind preference)
+        {
+            var val = Preferences.First(p => p.Kind == preference).Value;
+            if (string.IsNullOrEmpty(val))
+            {
+                return Array.Empty<T>();
+            }
+
+            // Convert array of {string} to array of {T}
+            var converter = TypeDescriptor.GetConverter(typeof(T));
+            var stringValues = val.Split(Delimiter);
+            var convertedCount = 0;
+            var parsedValues = new T[stringValues.Length];
+            for (var i = 0; i < stringValues.Length; i++)
+            {
+                try
+                {
+                    var parsedValue = converter.ConvertFromString(stringValues[i].Trim());
+                    if (parsedValue != null)
+                    {
+                        parsedValues[convertedCount++] = (T)parsedValue;
+                    }
+                }
+                catch (FormatException)
+                {
+                    // Unable to convert value
+                }
+            }
+
+            return parsedValues[..convertedCount];
+        }
+
         /// <summary>
         /// Sets the specified preference to the given value.
         /// </summary>
@@ -421,7 +459,19 @@ namespace Jellyfin.Data.Entities
         public void SetPreference(PreferenceKind preference, string[] values)
         {
             Preferences.First(p => p.Kind == preference).Value
-                = string.Join(Delimiter.ToString(CultureInfo.InvariantCulture), values);
+                = string.Join(Delimiter, values);
+        }
+
+        /// <summary>
+        /// Sets the specified preference to the given value.
+        /// </summary>
+        /// <param name="preference">The preference kind.</param>
+        /// <param name="values">The values.</param>
+        /// <typeparam name="T">The type of value.</typeparam>
+        public void SetPreference<T>(PreferenceKind preference, T[] values)
+        {
+            Preferences.First(p => p.Kind == preference).Value
+                = string.Join(Delimiter, values);
         }
 
         /// <summary>
@@ -441,7 +491,7 @@ namespace Jellyfin.Data.Entities
         /// <returns><c>True</c> if the folder is in the user's grouped folders.</returns>
         public bool IsFolderGrouped(Guid id)
         {
-            return GetPreference(PreferenceKind.GroupedFolders).Any(i => new Guid(i) == id);
+            return Array.IndexOf(GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders), id) != -1;
         }
 
         private static bool IsParentalScheduleAllowed(AccessSchedule schedule, DateTime date)

+ 8 - 10
Jellyfin.Server.Implementations/Users/UserManager.cs

@@ -375,14 +375,14 @@ namespace Jellyfin.Server.Implementations.Users
                     EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing),
                     AccessSchedules = user.AccessSchedules.ToArray(),
                     BlockedTags = user.GetPreference(PreferenceKind.BlockedTags),
-                    EnabledChannels = user.GetPreference(PreferenceKind.EnabledChannels)?.Select(Guid.Parse).ToArray(),
+                    EnabledChannels = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels),
                     EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices),
-                    EnabledFolders = user.GetPreference(PreferenceKind.EnabledFolders)?.Select(Guid.Parse).ToArray(),
+                    EnabledFolders = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders),
                     EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders),
                     SyncPlayAccess = user.SyncPlayAccess,
-                    BlockedChannels = user.GetPreference(PreferenceKind.BlockedChannels)?.Select(Guid.Parse).ToArray(),
-                    BlockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders)?.Select(Guid.Parse).ToArray(),
-                    BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems).Select(Enum.Parse<UnratedItem>).ToArray()
+                    BlockedChannels = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels),
+                    BlockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders),
+                    BlockUnratedItems = user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems)
                 }
             };
         }
@@ -703,13 +703,11 @@ namespace Jellyfin.Server.Implementations.Users
             }
 
             // TODO: fix this at some point
-            user.SetPreference(
-                PreferenceKind.BlockUnratedItems,
-                policy.BlockUnratedItems?.Select(i => i.ToString()).ToArray() ?? Array.Empty<string>());
+            user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
             user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
-            user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
+            user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
             user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
-            user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
+            user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
             user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
 
             dbContext.Update(user);

+ 12 - 3
Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs

@@ -8,7 +8,6 @@ using System.Text.Json.Serialization;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using Jellyfin.Server.Implementations;
-using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Model.Entities;
@@ -81,7 +80,8 @@ namespace Jellyfin.Server.Migrations.Routines
                 { "unstable", ChromecastVersion.Unstable }
             };
 
-            var customDisplayPrefs = new HashSet<string>();
+            var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+            var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
             var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
             using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null))
             {
@@ -98,6 +98,15 @@ namespace Jellyfin.Server.Migrations.Routines
 
                     var itemId = new Guid(result[1].ToBlob());
                     var dtoUserId = new Guid(result[1].ToBlob());
+                    var client = result[2].ToString();
+                    var displayPreferencesKey = $"{dtoUserId}|{itemId}|{client}";
+                    if (displayPrefs.Contains(displayPreferencesKey))
+                    {
+                        // Duplicate display preference.
+                        continue;
+                    }
+
+                    displayPrefs.Add(displayPreferencesKey);
                     var existingUser = _userManager.GetUserById(dtoUserId);
                     if (existingUser == null)
                     {
@@ -110,7 +119,7 @@ namespace Jellyfin.Server.Migrations.Routines
                         : ChromecastVersion.Stable;
                     dto.CustomPrefs.Remove("chromecastVersion");
 
-                    var displayPreferences = new DisplayPreferences(dtoUserId, itemId, result[2].ToString())
+                    var displayPreferences = new DisplayPreferences(dtoUserId, itemId, client)
                     {
                         IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null,
                         ShowBackdrop = dto.ShowBackdrop,

+ 2 - 2
Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs

@@ -168,9 +168,9 @@ namespace Jellyfin.Server.Migrations.Routines
                     }
 
                     user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
-                    user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
+                    user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
                     user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
-                    user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
+                    user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
                     user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
                     user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
                     user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);

+ 34 - 0
MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs

@@ -0,0 +1,34 @@
+using System;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+    /// <summary>
+    /// Legacy DateTime converter.
+    /// Milliseconds aren't output if zero by default.
+    /// </summary>
+    public class JsonDateTimeConverter : JsonConverter<DateTime>
+    {
+        /// <inheritdoc />
+        public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        {
+            return reader.GetDateTime();
+        }
+
+        /// <inheritdoc />
+        public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+        {
+            if (value.Millisecond == 0)
+            {
+                // Remaining ticks value will be 0, manually format.
+                writer.WriteStringValue(value.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffZ", CultureInfo.InvariantCulture));
+            }
+            else
+            {
+                writer.WriteStringValue(value);
+            }
+        }
+    }
+}

+ 47 - 32
MediaBrowser.Common/Json/JsonDefaults.cs

@@ -19,56 +19,71 @@ namespace MediaBrowser.Common.Json
         /// </summary>
         public const string CamelCaseMediaType = "application/json; profile=\"CamelCase\"";
 
+        /// <summary>
+        /// When changing these options, update
+        ///  Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+        ///   -> AddJellyfinApi
+        ///    -> AddJsonOptions.
+        /// </summary>
+        private static readonly JsonSerializerOptions _jsonSerializerOptions = new ()
+        {
+            ReadCommentHandling = JsonCommentHandling.Disallow,
+            WriteIndented = false,
+            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+            NumberHandling = JsonNumberHandling.AllowReadingFromString,
+            
+            Converters =
+            {
+                new JsonGuidConverter(),
+                new JsonVersionConverter(),
+                new JsonStringEnumConverter(),
+                new JsonNullableStructConverterFactory(),
+                new JsonBoolNumberConverter(),
+                new JsonDateTimeConverter()
+            }
+        };
+
+        private static readonly JsonSerializerOptions _pascalCaseJsonSerializerOptions = new (_jsonSerializerOptions)
+        {
+            PropertyNamingPolicy = null
+        };
+
+        private static readonly JsonSerializerOptions _camelCaseJsonSerializerOptions = new (_jsonSerializerOptions)
+        {
+            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+        };
+
         /// <summary>
         /// Gets the default <see cref="JsonSerializerOptions" /> options.
         /// </summary>
         /// <remarks>
-        /// When changing these options, update
-        ///     Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
-        ///         -> AddJellyfinApi
-        ///             -> AddJsonOptions.
+        /// The return value must not be modified.
+        /// If the defaults must be modified the author must use the copy constructor.
         /// </remarks>
         /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns>
         public static JsonSerializerOptions GetOptions()
-        {
-            var options = new JsonSerializerOptions
-            {
-                ReadCommentHandling = JsonCommentHandling.Disallow,
-                WriteIndented = false,
-                DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
-                NumberHandling = JsonNumberHandling.AllowReadingFromString,
-                PropertyNameCaseInsensitive = true
-            };
-
-            options.Converters.Add(new JsonGuidConverter());
-            options.Converters.Add(new JsonVersionConverter());
-            options.Converters.Add(new JsonStringEnumConverter());
-            options.Converters.Add(new JsonNullableStructConverterFactory());
-            options.Converters.Add(new JsonBoolNumberConverter());
-
-            return options;
-        }
+            => _jsonSerializerOptions;
 
         /// <summary>
         /// Gets camelCase json options.
         /// </summary>
+        /// <remarks>
+        /// The return value must not be modified.
+        /// If the defaults must be modified the author must use the copy constructor.
+        /// </remarks>
         /// <returns>The camelCase <see cref="JsonSerializerOptions" /> options.</returns>
         public static JsonSerializerOptions GetCamelCaseOptions()
-        {
-            var options = GetOptions();
-            options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
-            return options;
-        }
+            => _camelCaseJsonSerializerOptions;
 
         /// <summary>
         /// Gets PascalCase json options.
         /// </summary>
+        /// <remarks>
+        /// The return value must not be modified.
+        /// If the defaults must be modified the author must use the copy constructor.
+        /// </remarks>
         /// <returns>The PascalCase <see cref="JsonSerializerOptions" /> options.</returns>
         public static JsonSerializerOptions GetPascalCaseOptions()
-        {
-            var options = GetOptions();
-            options.PropertyNamingPolicy = null;
-            return options;
-        }
+            => _pascalCaseJsonSerializerOptions;
     }
 }

+ 1 - 1
MediaBrowser.Common/MediaBrowser.Common.csproj

@@ -14,6 +14,7 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <FrameworkReference Include="Microsoft.AspNetCore.App"/>
     <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
   </ItemGroup>
 
@@ -21,7 +22,6 @@
     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
     <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
-    <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
   </ItemGroup>
 
   <ItemGroup>

+ 4 - 4
MediaBrowser.Controller/Channels/Channel.cs

@@ -17,9 +17,10 @@ namespace MediaBrowser.Controller.Channels
     {
         public override bool IsVisible(User user)
         {
-            if (user.GetPreference(PreferenceKind.BlockedChannels) != null)
+            var blockedChannelsPreference = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels);
+            if (blockedChannelsPreference.Length != 0)
             {
-                if (user.GetPreference(PreferenceKind.BlockedChannels).Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase))
+                if (blockedChannelsPreference.Contains(Id))
                 {
                     return false;
                 }
@@ -27,8 +28,7 @@ namespace MediaBrowser.Controller.Channels
             else
             {
                 if (!user.HasPermission(PermissionKind.EnableAllChannels)
-                    && !user.GetPreference(PreferenceKind.EnabledChannels)
-                        .Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase))
+                    && !user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels).Contains(Id))
                 {
                     return false;
                 }

+ 1 - 1
MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs

@@ -120,7 +120,7 @@ namespace MediaBrowser.Controller.Entities.Audio
 
         protected override bool GetBlockUnratedValue(User user)
         {
-            return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music.ToString());
+            return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music);
         }
 
         public override UnratedItem GetBlockUnratedType()

+ 1 - 1
MediaBrowser.Controller/Entities/Audio/MusicArtist.cs

@@ -145,7 +145,7 @@ namespace MediaBrowser.Controller.Entities.Audio
 
         protected override bool GetBlockUnratedValue(User user)
         {
-            return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music.ToString());
+            return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music);
         }
 
         public override UnratedItem GetBlockUnratedType()

+ 4 - 4
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -480,11 +480,11 @@ namespace MediaBrowser.Controller.Entities
                 return true;
             }
 
-            var allowed = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders);
+            var allowed = user.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders);
 
             if (SourceType == SourceType.Channel)
             {
-                return allowed.Contains(ChannelId.ToString(""), StringComparer.OrdinalIgnoreCase);
+                return allowed.Contains(ChannelId);
             }
             else
             {
@@ -492,7 +492,7 @@ namespace MediaBrowser.Controller.Entities
 
                 foreach (var folder in collectionFolders)
                 {
-                    if (allowed.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase))
+                    if (allowed.Contains(folder.Id))
                     {
                         return true;
                     }
@@ -1909,7 +1909,7 @@ namespace MediaBrowser.Controller.Entities
                 return false;
             }
 
-            return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(GetBlockUnratedType().ToString());
+            return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(GetBlockUnratedType());
         }
 
         /// <summary>

+ 3 - 7
MediaBrowser.Controller/Entities/Folder.cs

@@ -186,13 +186,10 @@ namespace MediaBrowser.Controller.Entities
         {
             if (this is ICollectionFolder && !(this is BasePluginFolder))
             {
-                var blockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders);
+                var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders);
                 if (blockedMediaFolders.Length > 0)
                 {
-                    if (blockedMediaFolders.Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase) ||
-
-                        // Backwards compatibility
-                        blockedMediaFolders.Contains(Name, StringComparer.OrdinalIgnoreCase))
+                    if (blockedMediaFolders.Contains(Id))
                     {
                         return false;
                     }
@@ -200,8 +197,7 @@ namespace MediaBrowser.Controller.Entities
                 else
                 {
                     if (!user.HasPermission(PermissionKind.EnableAllFolders)
-                        && !user.GetPreference(PreferenceKind.EnabledFolders)
-                            .Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase))
+                        && !user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(Id))
                     {
                         return false;
                     }

+ 1 - 1
MediaBrowser.Controller/Entities/Movies/BoxSet.cs

@@ -49,7 +49,7 @@ namespace MediaBrowser.Controller.Entities.Movies
 
         protected override bool GetBlockUnratedValue(User user)
         {
-            return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Movie.ToString());
+            return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Movie);
         }
 
         public override double GetDefaultPrimaryImageAspectRatio()

+ 1 - 1
MediaBrowser.Controller/Entities/TV/Series.cs

@@ -452,7 +452,7 @@ namespace MediaBrowser.Controller.Entities.TV
 
         protected override bool GetBlockUnratedValue(User user)
         {
-            return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Series.ToString());
+            return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Series);
         }
 
         public override UnratedItem GetBlockUnratedType()

+ 30 - 11
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -1127,13 +1127,25 @@ namespace MediaBrowser.Controller.MediaEncoding
                 targetVideoCodec = "hevc";
             }
 
-            var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault();
-            profile =  Regex.Replace(profile, @"\s+", String.Empty);
+            var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault() ?? string.Empty;
+            profile = Regex.Replace(profile, @"\s+", string.Empty);
+
+            // We only transcode to HEVC 8-bit for now, force Main Profile.
+            if (profile.Contains("main 10", StringComparison.OrdinalIgnoreCase)
+                || profile.Contains("main still", StringComparison.OrdinalIgnoreCase))
+            {
+                profile = "main";
+            }
+
+            // Extended Profile is not supported by any known h264 encoders, force Main Profile.
+            if (profile.Contains("extended", StringComparison.OrdinalIgnoreCase))
+            {
+                profile = "main";
+            }
 
             // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile.
             if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
-                && profile != null
-                && profile.IndexOf("high 10", StringComparison.OrdinalIgnoreCase) != -1)
+                && profile.Contains("high 10", StringComparison.OrdinalIgnoreCase))
             {
                 profile = "high";
             }
@@ -1141,8 +1153,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case,
             // which is compatible (and ugly).
             if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
-                && profile != null
-                && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
+                && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase))
             {
                 profile = "constrained_baseline";
             }
@@ -1151,16 +1162,24 @@ namespace MediaBrowser.Controller.MediaEncoding
             if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
                  || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
                  || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase))
-                && profile != null
-                && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
+                && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase))
             {
                 profile = "baseline";
             }
 
+            // libx264, h264_qsv, h264_nvenc and h264_vaapi does not support Constrained High profile, force High in this case.
+            if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+                 || string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase))
+                && profile.Contains("high", StringComparison.OrdinalIgnoreCase))
+            {
+                profile = "high";
+            }
+
             // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile.
-            if (!string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)
-                && profile != null
-                && profile.IndexOf("main 10", StringComparison.OrdinalIgnoreCase) != -1)
+            if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)
+                && profile.Contains("main 10", StringComparison.OrdinalIgnoreCase))
             {
                 profile = "main";
             }

+ 1 - 1
MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj

@@ -24,7 +24,7 @@
 
   <ItemGroup>
     <PackageReference Include="BDInfo" Version="0.7.6.1" />
-    <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" />
+    <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
     <PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" />
     <PackageReference Include="UTF.Unknown" Version="2.3.0" />
   </ItemGroup>

+ 1 - 1
MediaBrowser.Model/Entities/ProviderIdsExtensions.cs

@@ -49,7 +49,7 @@ namespace MediaBrowser.Model.Entities
             }
 
             instance.ProviderIds.TryGetValue(name, out string? id);
-            return id;
+            return string.IsNullOrEmpty(id) ? null : id;
         }
 
         /// <summary>

+ 0 - 1
MediaBrowser.Model/MediaBrowser.Model.csproj

@@ -33,7 +33,6 @@
 
   <ItemGroup>
     <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
-    <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
     <PackageReference Include="System.Globalization" Version="4.3.0" />
     <PackageReference Include="System.Text.Json" Version="5.0.0" />

+ 0 - 1
MediaBrowser.Providers/MediaBrowser.Providers.csproj

@@ -22,7 +22,6 @@
     <PackageReference Include="OptimizedPriorityQueue" Version="5.0.0" />
     <PackageReference Include="PlaylistsNET" Version="1.1.3" />
     <PackageReference Include="TMDbLib" Version="1.7.3-alpha" />
-    <PackageReference Include="TvDbSharper" Version="3.2.2" />
   </ItemGroup>
 
   <PropertyGroup>

+ 132 - 1
MediaBrowser.Providers/TV/SeriesMetadataService.cs

@@ -1,10 +1,16 @@
 #pragma warning disable CS1591
 
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Providers.Manager;
 using Microsoft.Extensions.Logging;
@@ -13,14 +19,27 @@ namespace MediaBrowser.Providers.TV
 {
     public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
     {
+        private readonly ILocalizationManager _localizationManager;
+
         public SeriesMetadataService(
             IServerConfigurationManager serverConfigurationManager,
             ILogger<SeriesMetadataService> logger,
             IProviderManager providerManager,
             IFileSystem fileSystem,
-            ILibraryManager libraryManager)
+            ILibraryManager libraryManager,
+            ILocalizationManager localizationManager)
             : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
         {
+            _localizationManager = localizationManager;
+        }
+
+        /// <inheritdoc />
+        protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
+        {
+            await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
+
+            RemoveObsoleteSeasons(item);
+            await FillInMissingSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
         }
 
         /// <inheritdoc />
@@ -62,5 +81,117 @@ namespace MediaBrowser.Providers.TV
                 targetItem.AirDays = sourceItem.AirDays;
             }
         }
+
+        private void RemoveObsoleteSeasons(Series series)
+        {
+            // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in FillInMissingSeasonsAsync.
+            var physicalSeasonNumbers = new HashSet<int>();
+            var virtualSeasons = new List<Season>();
+            foreach (var existingSeason in series.Children.OfType<Season>())
+            {
+                if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
+                {
+                    physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
+                }
+                else if (existingSeason.LocationType == LocationType.Virtual)
+                {
+                    virtualSeasons.Add(existingSeason);
+                }
+            }
+
+            foreach (var virtualSeason in virtualSeasons)
+            {
+                var seasonNumber = virtualSeason.IndexNumber;
+                // If there's a physical season with the same number or no episodes in the season, delete it
+                if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
+                    || !virtualSeason.GetEpisodes().Any())
+                {
+                    Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.IndexNumber, series.Name);
+
+                    LibraryManager.DeleteItem(
+                        virtualSeason,
+                        new DeleteOptions
+                        {
+                            DeleteFileLocation = true
+                        },
+                        false);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Creates seasons for all episodes that aren't in a season folder.
+        /// If no season number can be determined, a dummy season will be created.
+        /// </summary>
+        /// <param name="series">The series.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>The async task.</returns>
+        private async Task FillInMissingSeasonsAsync(Series series, CancellationToken cancellationToken)
+        {
+            var episodesInSeriesFolder = series.GetRecursiveChildren(i => i is Episode)
+                .Cast<Episode>()
+                .Where(i => !i.IsInSeasonFolder);
+
+            List<Season> seasons = series.Children.OfType<Season>().ToList();
+
+            // Loop through the unique season numbers
+            foreach (var episode in episodesInSeriesFolder)
+            {
+                // Null season numbers will have a 'dummy' season created because seasons are always required.
+                var seasonNumber = episode.ParentIndexNumber >= 0 ? episode.ParentIndexNumber : null;
+                var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
+
+                if (existingSeason == null)
+                {
+                    var season = await CreateSeasonAsync(series, seasonNumber, cancellationToken).ConfigureAwait(false);
+                    seasons.Add(season);
+                }
+                else if (existingSeason.IsVirtualItem)
+                {
+                    existingSeason.IsVirtualItem = false;
+                    await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
+        /// </summary>
+        /// <param name="series">The series.</param>
+        /// <param name="seasonNumber">The season number.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>The newly created season.</returns>
+        private async Task<Season> CreateSeasonAsync(
+            Series series,
+            int? seasonNumber,
+            CancellationToken cancellationToken)
+        {
+            string seasonName = seasonNumber switch
+            {
+                null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
+                0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
+                _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value)
+            };
+
+            Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
+
+            var season = new Season
+            {
+                Name = seasonName,
+                IndexNumber = seasonNumber,
+                Id = LibraryManager.GetNewItemId(
+                    series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
+                    typeof(Season)),
+                IsVirtualItem = false,
+                SeriesId = series.Id,
+                SeriesName = series.Name
+            };
+
+            series.AddChild(season, cancellationToken);
+
+            await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false);
+
+            return season;
+        }
     }
 }

+ 31 - 0
deployment/Dockerfile.linux.amd64-musl

@@ -0,0 +1,31 @@
+FROM debian:10
+# Docker build arguments
+ARG SOURCE_DIR=/jellyfin
+ARG ARTIFACT_DIR=/dist
+ARG SDK_VERSION=5.0
+# Docker run environment
+ENV SOURCE_DIR=/jellyfin
+ENV ARTIFACT_DIR=/dist
+ENV DEB_BUILD_OPTIONS=noddebs
+ENV ARCH=amd64
+ENV IS_DOCKER=YES
+
+# Prepare Debian build environment
+RUN apt-get update \
+ && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0
+
+# Install dotnet repository
+# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+ && mkdir -p dotnet-sdk \
+ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
+ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
+
+# Link to docker-build script
+RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64-musl /build.sh
+
+VOLUME ${SOURCE_DIR}/
+
+VOLUME ${ARTIFACT_DIR}/
+
+ENTRYPOINT ["/build.sh"]

+ 31 - 0
deployment/Dockerfile.linux.arm64

@@ -0,0 +1,31 @@
+FROM debian:10
+# Docker build arguments
+ARG SOURCE_DIR=/jellyfin
+ARG ARTIFACT_DIR=/dist
+ARG SDK_VERSION=5.0
+# Docker run environment
+ENV SOURCE_DIR=/jellyfin
+ENV ARTIFACT_DIR=/dist
+ENV DEB_BUILD_OPTIONS=noddebs
+ENV ARCH=arm64
+ENV IS_DOCKER=YES
+
+# Prepare Debian build environment
+RUN apt-get update \
+ && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0
+
+# Install dotnet repository
+# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+ && mkdir -p dotnet-sdk \
+ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
+ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
+
+# Link to docker-build script
+RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.arm64 /build.sh
+
+VOLUME ${SOURCE_DIR}/
+
+VOLUME ${ARTIFACT_DIR}/
+
+ENTRYPOINT ["/build.sh"]

+ 31 - 0
deployment/Dockerfile.linux.armhf

@@ -0,0 +1,31 @@
+FROM debian:10
+# Docker build arguments
+ARG SOURCE_DIR=/jellyfin
+ARG ARTIFACT_DIR=/dist
+ARG SDK_VERSION=5.0
+# Docker run environment
+ENV SOURCE_DIR=/jellyfin
+ENV ARTIFACT_DIR=/dist
+ENV DEB_BUILD_OPTIONS=noddebs
+ENV ARCH=armhf
+ENV IS_DOCKER=YES
+
+# Prepare Debian build environment
+RUN apt-get update \
+ && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0
+
+# Install dotnet repository
+# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current
+RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+ && mkdir -p dotnet-sdk \
+ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
+ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet
+
+# Link to docker-build script
+RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.armhf /build.sh
+
+VOLUME ${SOURCE_DIR}/
+
+VOLUME ${ARTIFACT_DIR}/
+
+ENTRYPOINT ["/build.sh"]

+ 31 - 0
deployment/build.linux.amd64-musl

@@ -0,0 +1,31 @@
+#!/bin/bash
+
+#= Generic Linux amd64-musl .tar.gz
+
+set -o errexit
+set -o xtrace
+
+# Move to source directory
+pushd ${SOURCE_DIR}
+
+# Get version
+if [[ ${IS_UNSTABLE} == 'yes' ]]; then
+    version="${BUILD_ID}"
+else
+    version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )"
+fi
+
+# Build archives
+dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-musl-x64 --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true"
+tar -czf jellyfin-server_${version}_linux-amd64-musl.tar.gz -C dist jellyfin-server_${version}
+rm -rf dist/jellyfin-server_${version}
+
+# Move the artifacts out
+mkdir -p ${ARTIFACT_DIR}/
+mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/
+
+if [[ ${IS_DOCKER} == YES ]]; then
+    chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
+fi
+
+popd

+ 31 - 0
deployment/build.linux.arm64

@@ -0,0 +1,31 @@
+#!/bin/bash
+
+#= Generic Linux arm64 .tar.gz
+
+set -o errexit
+set -o xtrace
+
+# Move to source directory
+pushd ${SOURCE_DIR}
+
+# Get version
+if [[ ${IS_UNSTABLE} == 'yes' ]]; then
+    version="${BUILD_ID}"
+else
+    version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )"
+fi
+
+# Build archives
+dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm64 --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true"
+tar -czf jellyfin-server_${version}_linux-arm64.tar.gz -C dist jellyfin-server_${version}
+rm -rf dist/jellyfin-server_${version}
+
+# Move the artifacts out
+mkdir -p ${ARTIFACT_DIR}/
+mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/
+
+if [[ ${IS_DOCKER} == YES ]]; then
+    chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
+fi
+
+popd

+ 31 - 0
deployment/build.linux.armhf

@@ -0,0 +1,31 @@
+#!/bin/bash
+
+#= Generic Linux armhf .tar.gz
+
+set -o errexit
+set -o xtrace
+
+# Move to source directory
+pushd ${SOURCE_DIR}
+
+# Get version
+if [[ ${IS_UNSTABLE} == 'yes' ]]; then
+    version="${BUILD_ID}"
+else
+    version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )"
+fi
+
+# Build archives
+dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true"
+tar -czf jellyfin-server_${version}_linux-armhf.tar.gz -C dist jellyfin-server_${version}
+rm -rf dist/jellyfin-server_${version}
+
+# Move the artifacts out
+mkdir -p ${ARTIFACT_DIR}/
+mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/
+
+if [[ ${IS_DOCKER} == YES ]]; then
+    chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR}
+fi
+
+popd