Răsfoiți Sursa

Merge branch 'master' into generated-code-cleanup

Patrick Barron 5 ani în urmă
părinte
comite
0549d59a5f
63 a modificat fișierele cu 1326 adăugiri și 592 ștergeri
  1. 7 10
      .editorconfig
  2. 2 0
      DvdLib/BigEndianBinaryReader.cs
  3. 1 0
      DvdLib/DvdLib.csproj
  4. 2 0
      DvdLib/Ifo/Cell.cs
  5. 2 0
      DvdLib/Ifo/CellPlaybackInfo.cs
  6. 2 0
      DvdLib/Ifo/CellPositionInfo.cs
  7. 2 0
      DvdLib/Ifo/Chapter.cs
  8. 2 0
      DvdLib/Ifo/Dvd.cs
  9. 2 0
      DvdLib/Ifo/DvdTime.cs
  10. 2 0
      DvdLib/Ifo/Program.cs
  11. 2 0
      DvdLib/Ifo/ProgramChain.cs
  12. 2 0
      DvdLib/Ifo/Title.cs
  13. 2 0
      DvdLib/Ifo/UserOperation.cs
  14. 3 3
      Emby.Naming/Video/VideoResolver.cs
  15. 0 1
      Emby.Server.Implementations/ConfigurationOptions.cs
  16. 10 0
      Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
  17. 25 0
      Emby.Server.Implementations/Library/UserManager.cs
  18. 1 1
      Emby.Server.Implementations/Localization/Core/af.json
  19. 1 1
      Emby.Server.Implementations/Localization/Core/es-MX.json
  20. 1 1
      Emby.Server.Implementations/Localization/Core/es.json
  21. 13 13
      Emby.Server.Implementations/Localization/Core/fi.json
  22. 19 1
      Emby.Server.Implementations/Localization/Core/fr-CA.json
  23. 46 35
      Emby.Server.Implementations/Localization/Core/gsw.json
  24. 1 1
      Emby.Server.Implementations/Localization/Core/he.json
  25. 10 2
      Emby.Server.Implementations/Localization/Core/hr.json
  26. 8 1
      Emby.Server.Implementations/Localization/Core/mk.json
  27. 3 3
      Emby.Server.Implementations/Localization/Core/nl.json
  28. 22 1
      Emby.Server.Implementations/Localization/Core/sl-SI.json
  29. 4 3
      Emby.Server.Implementations/Localization/Core/sv.json
  30. 36 0
      Emby.Server.Implementations/Localization/Core/uk.json
  31. 5 2
      Emby.Server.Implementations/Localization/Core/zh-HK.json
  32. 7 1
      Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs
  33. 71 31
      Jellyfin.Server/Program.cs
  34. 8 5
      MediaBrowser.Api/Images/RemoteImageService.cs
  35. 9 5
      MediaBrowser.Api/ItemLookupService.cs
  36. 15 11
      MediaBrowser.Api/Playback/Hls/BaseHlsService.cs
  37. 189 8
      MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs
  38. 126 0
      MediaBrowser.Api/Playback/Hls/HlsCodecStringFactory.cs
  39. 27 11
      MediaBrowser.Api/UserService.cs
  40. 8 0
      MediaBrowser.Controller/Library/IUserManager.cs
  41. 2 2
      MediaBrowser.Model/Dlna/CodecProfile.cs
  42. 2 4
      MediaBrowser.Model/Dlna/ConditionProcessor.cs
  43. 4 4
      MediaBrowser.Model/Dlna/ContainerProfile.cs
  44. 13 12
      MediaBrowser.Model/Dlna/DeviceProfile.cs
  45. 3 2
      MediaBrowser.Model/Dlna/SubtitleProfile.cs
  46. 48 0
      MediaBrowser.Model/Dto/PublicUserDto.cs
  47. 0 27
      MediaBrowser.Model/Extensions/ListHelper.cs
  48. 67 50
      MediaBrowser.Model/Net/MimeTypes.cs
  49. 9 5
      MediaBrowser.Model/Notifications/NotificationOptions.cs
  50. 19 13
      MediaBrowser.sln
  51. 0 13
      tests/Jellyfin.Naming.Tests/Video/BaseVideoTest.cs
  52. 1 0
      tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs
  53. 1 1
      tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs
  54. 29 30
      tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs
  55. 3 2
      tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
  56. 4 2
      tests/Jellyfin.Naming.Tests/Video/StackTests.cs
  57. 5 5
      tests/Jellyfin.Naming.Tests/Video/StubTests.cs
  58. 2 2
      tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
  59. 192 267
      tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs
  60. 49 0
      tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs
  61. 120 0
      tests/MediaBrowser.Api.Tests/JellyfinApplicationFactory.cs
  62. 33 0
      tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj
  63. 22 0
      tests/jellyfin-tests.ruleset

+ 7 - 10
.editorconfig

@@ -13,7 +13,7 @@ charset = utf-8
 trim_trailing_whitespace = true
 insert_final_newline = true
 end_of_line = lf
-max_line_length = null
+max_line_length = off
 
 # YAML indentation
 [*.{yml,yaml}]
@@ -22,6 +22,7 @@ indent_size = 2
 # XML indentation
 [*.{csproj,xml}]
 indent_size = 2
+
 ###############################
 # .NET Coding Conventions     #
 ###############################
@@ -51,11 +52,12 @@ dotnet_style_explicit_tuple_names = true:suggestion
 dotnet_style_null_propagation = true:suggestion
 dotnet_style_coalesce_expression = true:suggestion
 dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent
-dotnet_prefer_inferred_tuple_names = true:suggestion
-dotnet_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
 dotnet_style_prefer_auto_properties = true:silent
 dotnet_style_prefer_conditional_expression_over_assignment = true:silent
 dotnet_style_prefer_conditional_expression_over_return = true:silent
+
 ###############################
 # Naming Conventions          #
 ###############################
@@ -67,7 +69,7 @@ dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non
 dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
 
 dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
-dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected internal, private protected
+dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
 dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
 
 dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
@@ -159,6 +161,7 @@ csharp_style_deconstructed_variable_declaration = true:suggestion
 csharp_prefer_simple_default_expression = true:suggestion
 csharp_style_pattern_local_over_anonymous_function = true:suggestion
 csharp_style_inlined_variable_declaration = true:suggestion
+
 ###############################
 # C# Formatting Rules         #
 ###############################
@@ -189,9 +192,3 @@ csharp_space_between_method_call_empty_parameter_list_parentheses = false
 # Wrapping preferences
 csharp_preserve_single_line_statements = true
 csharp_preserve_single_line_blocks = true
-###############################
-# VB Coding Conventions       #
-###############################
-[*.vb]
-# Modifier preferences
-visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion

+ 2 - 0
DvdLib/BigEndianBinaryReader.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System.Buffers.Binary;
 using System.IO;
 

+ 1 - 0
DvdLib/DvdLib.csproj

@@ -13,6 +13,7 @@
     <TargetFramework>netstandard2.1</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
   </PropertyGroup>
 
 </Project>

+ 2 - 0
DvdLib/Ifo/Cell.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System.IO;
 
 namespace DvdLib.Ifo

+ 2 - 0
DvdLib/Ifo/CellPlaybackInfo.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System.IO;
 
 namespace DvdLib.Ifo

+ 2 - 0
DvdLib/Ifo/CellPositionInfo.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System.IO;
 
 namespace DvdLib.Ifo

+ 2 - 0
DvdLib/Ifo/Chapter.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 namespace DvdLib.Ifo
 {
     public class Chapter

+ 2 - 0
DvdLib/Ifo/Dvd.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System;
 using System.Collections.Generic;
 using System.IO;

+ 2 - 0
DvdLib/Ifo/DvdTime.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System;
 
 namespace DvdLib.Ifo

+ 2 - 0
DvdLib/Ifo/Program.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System.Collections.Generic;
 
 namespace DvdLib.Ifo

+ 2 - 0
DvdLib/Ifo/ProgramChain.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;

+ 2 - 0
DvdLib/Ifo/Title.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System.Collections.Generic;
 using System.IO;
 

+ 2 - 0
DvdLib/Ifo/UserOperation.cs

@@ -1,3 +1,5 @@
+#pragma warning disable CS1591
+
 using System;
 
 namespace DvdLib.Ifo

+ 3 - 3
Emby.Naming/Video/VideoResolver.cs

@@ -89,14 +89,14 @@ namespace Emby.Naming.Video
             if (parseName)
             {
                 var cleanDateTimeResult = CleanDateTime(name);
+                name = cleanDateTimeResult.Name;
+                year = cleanDateTimeResult.Year;
 
                 if (extraResult.ExtraType == null
-                    && TryCleanString(cleanDateTimeResult.Name, out ReadOnlySpan<char> newName))
+                    && TryCleanString(name, out ReadOnlySpan<char> newName))
                 {
                     name = newName.ToString();
                 }
-
-                year = cleanDateTimeResult.Year;
             }
 
             return new VideoFileInfo

+ 0 - 1
Emby.Server.Implementations/ConfigurationOptions.cs

@@ -1,7 +1,6 @@
 using System.Collections.Generic;
 using Emby.Server.Implementations.HttpServer;
 using Emby.Server.Implementations.Updates;
-using MediaBrowser.Providers.Music;
 using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
 
 namespace Emby.Server.Implementations

+ 10 - 0
Emby.Server.Implementations/Data/SqliteUserDataRepository.cs

@@ -375,5 +375,15 @@ namespace Emby.Server.Implementations.Data
 
             return userData;
         }
+
+        /// <inheritdoc/>
+        /// <remarks>
+        /// There is nothing to dispose here since <see cref="BaseSqliteRepository.WriteLock"/> and
+        /// <see cref="BaseSqliteRepository.WriteConnection"/> are managed by <see cref="SqliteItemRepository"/>.
+        /// See <see cref="Initialize(IUserManager, SemaphoreSlim, SQLiteDatabaseConnection)"/>.
+        /// </remarks>
+        protected override void Dispose(bool dispose)
+        {
+        }
     }
 }

+ 25 - 0
Emby.Server.Implementations/Library/UserManager.cs

@@ -608,6 +608,31 @@ namespace Emby.Server.Implementations.Library
             return dto;
         }
 
+        public PublicUserDto GetPublicUserDto(User user, string remoteEndPoint = null)
+        {
+            if (user == null)
+            {
+                throw new ArgumentNullException(nameof(user));
+            }
+
+            IAuthenticationProvider authenticationProvider = GetAuthenticationProvider(user);
+            bool hasConfiguredPassword = authenticationProvider.HasPassword(user);
+            bool hasConfiguredEasyPassword = !string.IsNullOrEmpty(authenticationProvider.GetEasyPasswordHash(user));
+
+            bool hasPassword = user.Configuration.EnableLocalPassword &&
+                !string.IsNullOrEmpty(remoteEndPoint) &&
+                _networkManager.IsInLocalNetwork(remoteEndPoint) ? hasConfiguredEasyPassword : hasConfiguredPassword;
+
+            PublicUserDto dto = new PublicUserDto
+            {
+                Name = user.Name,
+                HasPassword = hasPassword,
+                HasConfiguredPassword = hasConfiguredPassword,
+            };
+
+            return dto;
+        }
+
         public UserDto GetOfflineUserDto(User user)
         {
             var dto = GetUserDto(user);

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

@@ -4,7 +4,7 @@
     "Folders": "Fouers",
     "Favorites": "Gunstelinge",
     "HeaderFavoriteShows": "Gunsteling Vertonings",
-    "ValueSpecialEpisodeName": "Spesiaal - {0}",
+    "ValueSpecialEpisodeName": "Spesiale - {0}",
     "HeaderAlbumArtists": "Album Kunstenaars",
     "Books": "Boeke",
     "HeaderNextUp": "Volgende",

+ 1 - 1
Emby.Server.Implementations/Localization/Core/es-MX.json

@@ -11,7 +11,7 @@
     "Collections": "Colecciones",
     "DeviceOfflineWithName": "{0} se ha desconectado",
     "DeviceOnlineWithName": "{0} está conectado",
-    "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}",
+    "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}",
     "Favorites": "Favoritos",
     "Folders": "Carpetas",
     "Genres": "Géneros",

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

@@ -71,7 +71,7 @@
     "ScheduledTaskFailedWithName": "{0} falló",
     "ScheduledTaskStartedWithName": "{0} iniciada",
     "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado",
-    "Shows": "Series",
+    "Shows": "Mostrar",
     "Songs": "Canciones",
     "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.",
     "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}",

+ 13 - 13
Emby.Server.Implementations/Localization/Core/fi.json

@@ -1,5 +1,5 @@
 {
-    "HeaderLiveTV": "Suorat lähetykset",
+    "HeaderLiveTV": "Live-TV",
     "NewVersionIsAvailable": "Uusi versio Jellyfin palvelimesta on ladattavissa.",
     "NameSeasonUnknown": "Tuntematon Kausi",
     "NameSeasonNumber": "Kausi {0}",
@@ -12,7 +12,7 @@
     "MessageNamedServerConfigurationUpdatedWithValue": "Palvelimen asetusryhmä {0} on päivitetty",
     "MessageApplicationUpdatedTo": "Jellyfin palvelin on päivitetty versioon {0}",
     "MessageApplicationUpdated": "Jellyfin palvelin on päivitetty",
-    "Latest": "Viimeisin",
+    "Latest": "Uusimmat",
     "LabelRunningTimeValue": "Toiston kesto: {0}",
     "LabelIpAddressValue": "IP-osoite: {0}",
     "ItemRemovedWithName": "{0} poistettiin kirjastosta",
@@ -41,7 +41,7 @@
     "CameraImageUploadedFrom": "Uusi kamerakuva on ladattu {0}",
     "Books": "Kirjat",
     "AuthenticationSucceededWithUserName": "{0} todennus onnistui",
-    "Artists": "Esiintyjät",
+    "Artists": "Artistit",
     "Application": "Sovellus",
     "AppDeviceValues": "Sovellus: {0}, Laite: {1}",
     "Albums": "Albumit",
@@ -67,21 +67,21 @@
     "UserDownloadingItemWithValues": "{0} lataa {1}",
     "UserDeletedWithName": "Käyttäjä {0} poistettu",
     "UserCreatedWithName": "Käyttäjä {0} luotu",
-    "TvShows": "TV-Ohjelmat",
+    "TvShows": "TV-sarjat",
     "Sync": "Synkronoi",
-    "SubtitleDownloadFailureFromForItem": "Tekstityksen lataaminen epäonnistui {0} - {1}",
+    "SubtitleDownloadFailureFromForItem": "Tekstitysten lataus ({0} -> {1}) epäonnistui //this string would have to be generated for each provider and movie because of finnish cases, sorry",
     "StartupEmbyServerIsLoading": "Jellyfin palvelin latautuu. Kokeile hetken kuluttua uudelleen.",
     "Songs": "Kappaleet",
-    "Shows": "Ohjelmat",
-    "ServerNameNeedsToBeRestarted": "{0} vaatii uudelleenkäynnistyksen",
+    "Shows": "Sarjat",
+    "ServerNameNeedsToBeRestarted": "{0} täytyy käynnistää uudelleen",
     "ProviderValue": "Tarjoaja: {0}",
     "Plugin": "Liitännäinen",
     "NotificationOptionVideoPlaybackStopped": "Videon toisto pysäytetty",
-    "NotificationOptionVideoPlayback": "Videon toisto aloitettu",
-    "NotificationOptionUserLockedOut": "Käyttäjä lukittu",
+    "NotificationOptionVideoPlayback": "Videota toistetaan",
+    "NotificationOptionUserLockedOut": "Käyttäjä kirjautui ulos",
     "NotificationOptionTaskFailed": "Ajastettu tehtävä epäonnistui",
-    "NotificationOptionServerRestartRequired": "Palvelimen uudelleenkäynnistys vaaditaan",
-    "NotificationOptionPluginUpdateInstalled": "Lisäosan päivitys asennettu",
+    "NotificationOptionServerRestartRequired": "Palvelin pitää käynnistää uudelleen",
+    "NotificationOptionPluginUpdateInstalled": "Liitännäinen päivitetty",
     "NotificationOptionPluginUninstalled": "Liitännäinen poistettu",
     "NotificationOptionPluginInstalled": "Liitännäinen asennettu",
     "NotificationOptionPluginError": "Ongelma liitännäisessä",
@@ -90,8 +90,8 @@
     "NotificationOptionCameraImageUploaded": "Kameran kuva ladattu",
     "NotificationOptionAudioPlaybackStopped": "Äänen toisto lopetettu",
     "NotificationOptionAudioPlayback": "Toistetaan ääntä",
-    "NotificationOptionApplicationUpdateInstalled": "Uusi sovellusversio asennettu",
-    "NotificationOptionApplicationUpdateAvailable": "Sovelluksesta on uusi versio saatavilla",
+    "NotificationOptionApplicationUpdateInstalled": "Sovelluspäivitys asennettu",
+    "NotificationOptionApplicationUpdateAvailable": "Ohjelmistopäivitys saatavilla",
     "TasksMaintenanceCategory": "Ylläpito",
     "TaskDownloadMissingSubtitlesDescription": "Etsii puuttuvia tekstityksiä videon metadatatietojen pohjalta.",
     "TaskDownloadMissingSubtitles": "Lataa puuttuvat tekstitykset",

+ 19 - 1
Emby.Server.Implementations/Localization/Core/fr-CA.json

@@ -94,5 +94,23 @@
     "ValueSpecialEpisodeName": "Spécial - {0}",
     "VersionNumber": "Version {0}",
     "TasksLibraryCategory": "Bibliothèque",
-    "TasksMaintenanceCategory": "Entretien"
+    "TasksMaintenanceCategory": "Entretien",
+    "TaskDownloadMissingSubtitlesDescription": "Recherche l'internet pour des sous-titres manquants à base de métadonnées configurées.",
+    "TaskDownloadMissingSubtitles": "Télécharger des sous-titres manquants",
+    "TaskRefreshChannelsDescription": "Rafraîchit des informations des chaines d'internet.",
+    "TaskRefreshChannels": "Rafraîchir des chaines",
+    "TaskCleanTranscodeDescription": "Retirer des fichiers de transcodage de plus qu'un jour.",
+    "TaskCleanTranscode": "Nettoyer le directoire de transcodage",
+    "TaskUpdatePluginsDescription": "Télécharger et installer des mises à jours des plugins qui sont configurés m.à.j. automisés.",
+    "TaskUpdatePlugins": "Mise à jour des plugins",
+    "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque.",
+    "TaskRefreshPeople": "Rafraîchir les acteurs",
+    "TaskCleanLogsDescription": "Retire les données qui ont plus que {0} jours.",
+    "TaskCleanLogs": "Nettoyer les données de directoire",
+    "TaskRefreshLibraryDescription": "Analyse votre bibliothèque média pour des nouveaux fichiers et rafraîchit les métadonnées.",
+    "TaskRefreshChapterImages": "Extraire des images du chapitre",
+    "TaskRefreshChapterImagesDescription": "Créer des vignettes pour des vidéos qui ont des chapitres",
+    "TaskRefreshLibrary": "Analyser la bibliothèque de média",
+    "TaskCleanCache": "Nettoyer le cache de directoire",
+    "TasksApplicationCategory": "Application"
 }

+ 46 - 35
Emby.Server.Implementations/Localization/Core/gsw.json

@@ -1,41 +1,41 @@
 {
-    "Albums": "Albom",
-    "AppDeviceValues": "App: {0}, Grät: {1}",
-    "Application": "Aawändig",
-    "Artists": "Könstler",
-    "AuthenticationSucceededWithUserName": "{0} het sech aagmäudet",
-    "Books": "Büecher",
-    "CameraImageUploadedFrom": "Es nöis Foti esch ufeglade worde vo {0}",
-    "Channels": "Kanäu",
-    "ChapterNameValue": "Kapitu {0}",
-    "Collections": "Sammlige",
-    "DeviceOfflineWithName": "{0} esch offline gange",
-    "DeviceOnlineWithName": "{0} esch online cho",
-    "FailedLoginAttemptWithUserName": "Fäugschlagne Aamäudeversuech vo {0}",
-    "Favorites": "Favorite",
+    "Albums": "Alben",
+    "AppDeviceValues": "App: {0}, Gerät: {1}",
+    "Application": "Anwendung",
+    "Artists": "Künstler",
+    "AuthenticationSucceededWithUserName": "{0} hat sich angemeldet",
+    "Books": "Bücher",
+    "CameraImageUploadedFrom": "Ein neues Foto wurde von {0} hochgeladen",
+    "Channels": "Kanäle",
+    "ChapterNameValue": "Kapitel {0}",
+    "Collections": "Sammlungen",
+    "DeviceOfflineWithName": "{0} wurde getrennt",
+    "DeviceOnlineWithName": "{0} ist verbunden",
+    "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
+    "Favorites": "Favoriten",
     "Folders": "Ordner",
     "Genres": "Genres",
-    "HeaderAlbumArtists": "Albom-Könstler",
+    "HeaderAlbumArtists": "Album-Künstler",
     "HeaderCameraUploads": "Kamera-Uploads",
-    "HeaderContinueWatching": "Wiiterluege",
-    "HeaderFavoriteAlbums": "Lieblingsalbe",
-    "HeaderFavoriteArtists": "Lieblings-Interprete",
-    "HeaderFavoriteEpisodes": "Lieblingsepisode",
-    "HeaderFavoriteShows": "Lieblingsserie",
+    "HeaderContinueWatching": "weiter schauen",
+    "HeaderFavoriteAlbums": "Lieblingsalben",
+    "HeaderFavoriteArtists": "Lieblings-Künstler",
+    "HeaderFavoriteEpisodes": "Lieblingsepisoden",
+    "HeaderFavoriteShows": "Lieblingsserien",
     "HeaderFavoriteSongs": "Lieblingslieder",
-    "HeaderLiveTV": "Live-Färnseh",
-    "HeaderNextUp": "Als nächts",
-    "HeaderRecordingGroups": "Ufnahmegruppe",
-    "HomeVideos": "Heimfilmli",
-    "Inherit": "Hinzuefüege",
-    "ItemAddedWithName": "{0} esch de Bibliothek dezuegfüegt worde",
-    "ItemRemovedWithName": "{0} esch vo de Bibliothek entfärnt worde",
-    "LabelIpAddressValue": "IP-Adrässe: {0}",
-    "LabelRunningTimeValue": "Loufziit: {0}",
-    "Latest": "Nöischti",
-    "MessageApplicationUpdated": "Jellyfin Server esch aktualisiert worde",
-    "MessageApplicationUpdatedTo": "Jellyfin Server esch of Version {0} aktualisiert worde",
-    "MessageNamedServerConfigurationUpdatedWithValue": "De Serveriistöuigsberiich {0} esch aktualisiert worde",
+    "HeaderLiveTV": "Live-Fernseh",
+    "HeaderNextUp": "Als Nächstes",
+    "HeaderRecordingGroups": "Aufnahme-Gruppen",
+    "HomeVideos": "Heimvideos",
+    "Inherit": "Vererben",
+    "ItemAddedWithName": "{0} wurde der Bibliothek hinzugefügt",
+    "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt",
+    "LabelIpAddressValue": "IP-Adresse: {0}",
+    "LabelRunningTimeValue": "Laufzeit: {0}",
+    "Latest": "Neueste",
+    "MessageApplicationUpdated": "Jellyfin-Server wurde aktualisiert",
+    "MessageApplicationUpdatedTo": "Jellyfin-Server wurde auf Version {0} aktualisiert",
+    "MessageNamedServerConfigurationUpdatedWithValue": "Der Server-Einstellungsbereich {0} wurde aktualisiert",
     "MessageServerConfigurationUpdated": "Serveriistöuige send aktualisiert worde",
     "MixedContent": "Gmeschti Inhäut",
     "Movies": "Film",
@@ -50,7 +50,7 @@
     "NotificationOptionAudioPlayback": "Audiowedergab gstartet",
     "NotificationOptionAudioPlaybackStopped": "Audiwedergab gstoppt",
     "NotificationOptionCameraImageUploaded": "Foti ueglade",
-    "NotificationOptionInstallationFailed": "Installationsfäuer",
+    "NotificationOptionInstallationFailed": "Installationsfehler",
     "NotificationOptionNewLibraryContent": "Nöie Inhaut hinzuegfüegt",
     "NotificationOptionPluginError": "Plugin-Fäuer",
     "NotificationOptionPluginInstalled": "Plugin installiert",
@@ -92,5 +92,16 @@
     "UserStoppedPlayingItemWithValues": "{0} het d'Wedergab vo {1} of {2} gstoppt",
     "ValueHasBeenAddedToLibrary": "{0} esch dinnere Biblithek hinzuegfüegt worde",
     "ValueSpecialEpisodeName": "Extra - {0}",
-    "VersionNumber": "Version {0}"
+    "VersionNumber": "Version {0}",
+    "TaskCleanLogs": "Lösche Log Pfad",
+    "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.",
+    "TaskRefreshLibrary": "Scanne alle Bibliotheken",
+    "TaskRefreshChapterImagesDescription": "Kreiert Vorschaubilder für Videos welche Kapitel haben.",
+    "TaskRefreshChapterImages": "Extrahiere Kapitel-Bilder",
+    "TaskCleanCacheDescription": "Löscht Zwischenspeicherdatein die nicht länger von System gebraucht werden.",
+    "TaskCleanCache": "Leere Cache Pfad",
+    "TasksChannelsCategory": "Internet Kanäle",
+    "TasksApplicationCategory": "Applikation",
+    "TasksLibraryCategory": "Bibliothek",
+    "TasksMaintenanceCategory": "Verwaltung"
 }

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

@@ -62,7 +62,7 @@
     "NotificationOptionVideoPlayback": "Video playback started",
     "NotificationOptionVideoPlaybackStopped": "Video playback stopped",
     "Photos": "תמונות",
-    "Playlists": "רשימות ניגון",
+    "Playlists": "רשימות הפעלה",
     "Plugin": "Plugin",
     "PluginInstalledWithName": "{0} was installed",
     "PluginUninstalledWithName": "{0} was uninstalled",

+ 10 - 2
Emby.Server.Implementations/Localization/Core/hr.json

@@ -30,7 +30,7 @@
     "Inherit": "Naslijedi",
     "ItemAddedWithName": "{0} je dodano u biblioteku",
     "ItemRemovedWithName": "{0} je uklonjen iz biblioteke",
-    "LabelIpAddressValue": "Ip adresa: {0}",
+    "LabelIpAddressValue": "IP adresa: {0}",
     "LabelRunningTimeValue": "Vrijeme rada: {0}",
     "Latest": "Najnovije",
     "MessageApplicationUpdated": "Jellyfin Server je ažuriran",
@@ -92,5 +92,13 @@
     "UserStoppedPlayingItemWithValues": "{0} je zaustavio {1}",
     "ValueHasBeenAddedToLibrary": "{0} has been added to your media library",
     "ValueSpecialEpisodeName": "Specijal - {0}",
-    "VersionNumber": "Verzija {0}"
+    "VersionNumber": "Verzija {0}",
+    "TaskRefreshLibraryDescription": "Skenira vašu medijsku knjižnicu sa novim datotekama i osvježuje metapodatke.",
+    "TaskRefreshLibrary": "Skeniraj medijsku knjižnicu",
+    "TaskRefreshChapterImagesDescription": "Stvara sličice za videozapise koji imaju poglavlja.",
+    "TaskRefreshChapterImages": "Raspakiraj slike poglavlja",
+    "TaskCleanCacheDescription": "Briše priručne datoteke nepotrebne za sistem.",
+    "TaskCleanCache": "Očisti priručnu memoriju",
+    "TasksApplicationCategory": "Aplikacija",
+    "TasksMaintenanceCategory": "Održavanje"
 }

+ 8 - 1
Emby.Server.Implementations/Localization/Core/mk.json

@@ -91,5 +91,12 @@
     "Songs": "Песни",
     "Shows": "Серии",
     "ServerNameNeedsToBeRestarted": "{0} треба да се рестартира",
-    "ScheduledTaskStartedWithName": "{0} започна"
+    "ScheduledTaskStartedWithName": "{0} започна",
+    "TaskRefreshChapterImages": "Извези Слики од Поглавје",
+    "TaskCleanCacheDescription": "Ги брише кешираните фајлови што не се повеќе потребни од системот.",
+    "TaskCleanCache": "Исчисти Го Кешот",
+    "TasksChannelsCategory": "Интернет Канали",
+    "TasksApplicationCategory": "Апликација",
+    "TasksLibraryCategory": "Библиотека",
+    "TasksMaintenanceCategory": "Одржување"
 }

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

@@ -5,7 +5,7 @@
     "Artists": "Artiesten",
     "AuthenticationSucceededWithUserName": "{0} is succesvol geverifieerd",
     "Books": "Boeken",
-    "CameraImageUploadedFrom": "Er is een nieuwe afbeelding toegevoegd via {0}",
+    "CameraImageUploadedFrom": "Er is een nieuwe camera afbeelding toegevoegd via {0}",
     "Channels": "Kanalen",
     "ChapterNameValue": "Hoofdstuk {0}",
     "Collections": "Verzamelingen",
@@ -26,7 +26,7 @@
     "HeaderLiveTV": "Live TV",
     "HeaderNextUp": "Volgende",
     "HeaderRecordingGroups": "Opnamegroepen",
-    "HomeVideos": "Start video's",
+    "HomeVideos": "Home video's",
     "Inherit": "Overerven",
     "ItemAddedWithName": "{0} is toegevoegd aan de bibliotheek",
     "ItemRemovedWithName": "{0} is verwijderd uit de bibliotheek",
@@ -50,7 +50,7 @@
     "NotificationOptionAudioPlayback": "Muziek gestart",
     "NotificationOptionAudioPlaybackStopped": "Muziek gestopt",
     "NotificationOptionCameraImageUploaded": "Camera-afbeelding geüpload",
-    "NotificationOptionInstallationFailed": "Installatie mislukking",
+    "NotificationOptionInstallationFailed": "Installatie mislukt",
     "NotificationOptionNewLibraryContent": "Nieuwe content toegevoegd",
     "NotificationOptionPluginError": "Plug-in fout",
     "NotificationOptionPluginInstalled": "Plug-in geïnstalleerd",

+ 22 - 1
Emby.Server.Implementations/Localization/Core/sl-SI.json

@@ -92,5 +92,26 @@
     "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}",
     "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici",
     "ValueSpecialEpisodeName": "Poseben - {0}",
-    "VersionNumber": "Različica {0}"
+    "VersionNumber": "Različica {0}",
+    "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise",
+    "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.",
+    "TaskRefreshChannels": "Osveži kanale",
+    "TaskCleanTranscodeDescription": "Izbriše več kot dan stare datoteke prekodiranja.",
+    "TaskCleanTranscode": "Počisti mapo prekodiranja",
+    "TaskUpdatePluginsDescription": "Prenese in namesti posodobitve za dodatke, ki imajo omogočene samodejne posodobitve.",
+    "TaskUpdatePlugins": "Posodobi dodatke",
+    "TaskRefreshPeopleDescription": "Osveži metapodatke za igralce in režiserje v vaši knjižnici.",
+    "TaskRefreshPeople": "Osveži osebe",
+    "TaskCleanLogsDescription": "Izbriše dnevniške datoteke starejše od {0} dni.",
+    "TaskCleanLogs": "Počisti mapo dnevnika",
+    "TaskRefreshLibraryDescription": "Preišče vašo knjižnico za nove datoteke in osveži metapodatke.",
+    "TaskRefreshLibrary": "Preišči knjižnico predstavnosti",
+    "TaskRefreshChapterImagesDescription": "Ustvari sličice za poglavja videoposnetkov.",
+    "TaskRefreshChapterImages": "Izvleči slike poglavij",
+    "TaskCleanCacheDescription": "Izbriše predpomnjene datoteke, ki niso več potrebne.",
+    "TaskCleanCache": "Počisti mapo predpomnilnika",
+    "TasksChannelsCategory": "Spletni kanali",
+    "TasksApplicationCategory": "Aplikacija",
+    "TasksLibraryCategory": "Knjižnica",
+    "TasksMaintenanceCategory": "Vzdrževanje"
 }

+ 4 - 3
Emby.Server.Implementations/Localization/Core/sv.json

@@ -9,7 +9,7 @@
     "Channels": "Kanaler",
     "ChapterNameValue": "Kapitel {0}",
     "Collections": "Samlingar",
-    "DeviceOfflineWithName": "{0} har tappat anslutningen",
+    "DeviceOfflineWithName": "{0} har kopplat från",
     "DeviceOnlineWithName": "{0} är ansluten",
     "FailedLoginAttemptWithUserName": "Misslyckat inloggningsförsök från {0}",
     "Favorites": "Favoriter",
@@ -50,7 +50,7 @@
     "NotificationOptionAudioPlayback": "Ljuduppspelning har påbörjats",
     "NotificationOptionAudioPlaybackStopped": "Ljuduppspelning stoppades",
     "NotificationOptionCameraImageUploaded": "Kamerabild har laddats upp",
-    "NotificationOptionInstallationFailed": "Fel vid installation",
+    "NotificationOptionInstallationFailed": "Installationen misslyckades",
     "NotificationOptionNewLibraryContent": "Nytt innehåll har lagts till",
     "NotificationOptionPluginError": "Fel uppstod med tillägget",
     "NotificationOptionPluginInstalled": "Tillägg har installerats",
@@ -113,5 +113,6 @@
     "TasksChannelsCategory": "Internetkanaler",
     "TasksApplicationCategory": "Applikation",
     "TasksLibraryCategory": "Bibliotek",
-    "TasksMaintenanceCategory": "Underhåll"
+    "TasksMaintenanceCategory": "Underhåll",
+    "TaskRefreshPeople": "Uppdatera Personer"
 }

+ 36 - 0
Emby.Server.Implementations/Localization/Core/uk.json

@@ -0,0 +1,36 @@
+{
+    "MusicVideos": "Музичні відео",
+    "Music": "Музика",
+    "Movies": "Фільми",
+    "MessageApplicationUpdatedTo": "Jellyfin Server був оновлений до версії {0}",
+    "MessageApplicationUpdated": "Jellyfin Server був оновлений",
+    "Latest": "Останні",
+    "LabelIpAddressValue": "IP-адреси: {0}",
+    "ItemRemovedWithName": "{0} видалено з бібліотеки",
+    "ItemAddedWithName": "{0} додано до бібліотеки",
+    "HeaderNextUp": "Наступний",
+    "HeaderLiveTV": "Ефірне ТБ",
+    "HeaderFavoriteSongs": "Улюблені пісні",
+    "HeaderFavoriteShows": "Улюблені шоу",
+    "HeaderFavoriteEpisodes": "Улюблені серії",
+    "HeaderFavoriteArtists": "Улюблені виконавці",
+    "HeaderFavoriteAlbums": "Улюблені альбоми",
+    "HeaderContinueWatching": "Продовжити перегляд",
+    "HeaderCameraUploads": "Завантажено з камери",
+    "HeaderAlbumArtists": "Виконавці альбомів",
+    "Genres": "Жанри",
+    "Folders": "Директорії",
+    "Favorites": "Улюблені",
+    "DeviceOnlineWithName": "{0} під'єднано",
+    "DeviceOfflineWithName": "{0} від'єднано",
+    "Collections": "Колекції",
+    "ChapterNameValue": "Глава {0}",
+    "Channels": "Канали",
+    "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}",
+    "Books": "Книги",
+    "AuthenticationSucceededWithUserName": "{0} успішно авторизовані",
+    "Artists": "Виконавці",
+    "Application": "Додаток",
+    "AppDeviceValues": "Додаток: {0}, Пристрій: {1}",
+    "Albums": "Альбоми"
+}

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

@@ -1,6 +1,6 @@
 {
     "Albums": "專輯",
-    "AppDeviceValues": "軟: {0}, 設備: {1}",
+    "AppDeviceValues": "軟: {0}, 設備: {1}",
     "Application": "應用程式",
     "Artists": "藝人",
     "AuthenticationSucceededWithUserName": "{0} 授權成功",
@@ -92,5 +92,8 @@
     "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}",
     "ValueHasBeenAddedToLibrary": "{0} 已添加到你的媒體庫",
     "ValueSpecialEpisodeName": "特典 - {0}",
-    "VersionNumber": "版本{0}"
+    "VersionNumber": "版本{0}",
+    "TaskDownloadMissingSubtitles": "下載遺失的字幕",
+    "TaskUpdatePlugins": "更新插件",
+    "TasksApplicationCategory": "應用程式"
 }

+ 7 - 1
Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs

@@ -63,6 +63,9 @@ namespace Emby.Server.Implementations.SocketSharp
                     if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XRealIP), out ip))
                     {
                         ip = Request.HttpContext.Connection.RemoteIpAddress;
+
+                        // Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests)
+                        ip ??= IPAddress.Loopback;
                     }
                 }
 
@@ -90,7 +93,10 @@ namespace Emby.Server.Implementations.SocketSharp
 
         public IQueryCollection QueryString => Request.Query;
 
-        public bool IsLocal => Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress);
+        public bool IsLocal =>
+            (Request.HttpContext.Connection.LocalIpAddress == null
+            && Request.HttpContext.Connection.RemoteIpAddress == null)
+            || Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress);
 
         public string HttpMethod => Request.Method;
 

+ 71 - 31
Jellyfin.Server/Program.cs

@@ -161,23 +161,7 @@ namespace Jellyfin.Server
 
             ApplicationHost.LogEnvironmentInfo(_logger, appPaths);
 
-            // Make sure we have all the code pages we can get
-            // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks
-            Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
-
-            // Increase the max http request limit
-            // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others.
-            ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit);
-
-            // Disable the "Expect: 100-Continue" header by default
-            // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c
-            ServicePointManager.Expect100Continue = false;
-
-            Batteries_V2.Init();
-            if (raw.sqlite3_enable_shared_cache(1) != raw.SQLITE_OK)
-            {
-                _logger.LogWarning("Failed to enable shared cache for SQLite");
-            }
+            PerformStaticInitialization();
 
             var appHost = new CoreAppHost(
                 appPaths,
@@ -205,7 +189,7 @@ namespace Jellyfin.Server
                 ServiceCollection serviceCollection = new ServiceCollection();
                 appHost.Init(serviceCollection);
 
-                var webHost = CreateWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build();
+                var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build();
 
                 // Re-use the web host service provider in the app host since ASP.NET doesn't allow a custom service collection.
                 appHost.ServiceProvider = webHost.Services;
@@ -250,14 +234,49 @@ namespace Jellyfin.Server
             }
         }
 
-        private static IWebHostBuilder CreateWebHostBuilder(
+        /// <summary>
+        /// Call static initialization methods for the application.
+        /// </summary>
+        public static void PerformStaticInitialization()
+        {
+            // Make sure we have all the code pages we can get
+            // Ref: https://docs.microsoft.com/en-us/dotnet/api/system.text.codepagesencodingprovider.instance?view=netcore-3.0#remarks
+            Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+
+            // Increase the max http request limit
+            // The default connection limit is 10 for ASP.NET hosted applications and 2 for all others.
+            ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit);
+
+            // Disable the "Expect: 100-Continue" header by default
+            // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c
+            ServicePointManager.Expect100Continue = false;
+
+            Batteries_V2.Init();
+            if (raw.sqlite3_enable_shared_cache(1) != raw.SQLITE_OK)
+            {
+                _logger.LogWarning("Failed to enable shared cache for SQLite");
+            }
+        }
+
+        /// <summary>
+        /// Configure the web host builder.
+        /// </summary>
+        /// <param name="builder">The builder to configure.</param>
+        /// <param name="appHost">The application host.</param>
+        /// <param name="serviceCollection">The application service collection.</param>
+        /// <param name="commandLineOpts">The command line options passed to the application.</param>
+        /// <param name="startupConfig">The application configuration.</param>
+        /// <param name="appPaths">The application paths.</param>
+        /// <returns>The configured web host builder.</returns>
+        public static IWebHostBuilder ConfigureWebHostBuilder(
+            this IWebHostBuilder builder,
             ApplicationHost appHost,
             IServiceCollection serviceCollection,
             StartupOptions commandLineOpts,
             IConfiguration startupConfig,
             IApplicationPaths appPaths)
         {
-            return new WebHostBuilder()
+            return builder
                 .UseKestrel((builderContext, options) =>
                 {
                     var addresses = appHost.ServerConfigurationManager
@@ -278,7 +297,6 @@ namespace Jellyfin.Server
                         {
                             _logger.LogInformation("Kestrel listening on {IpAddress}", address);
                             options.Listen(address, appHost.HttpPort);
-
                             if (appHost.EnableHttps && appHost.Certificate != null)
                             {
                                 options.Listen(address, appHost.HttpsPort, listenOptions =>
@@ -289,11 +307,18 @@ namespace Jellyfin.Server
                             }
                             else if (builderContext.HostingEnvironment.IsDevelopment())
                             {
-                                options.Listen(address, appHost.HttpsPort, listenOptions =>
+                                try
                                 {
-                                    listenOptions.UseHttps();
-                                    listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
-                                });
+                                    options.Listen(address, appHost.HttpsPort, listenOptions =>
+                                    {
+                                        listenOptions.UseHttps();
+                                        listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
+                                    });
+                                }
+                                catch (InvalidOperationException ex)
+                                {
+                                    _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
+                                }
                             }
                         }
                     }
@@ -312,11 +337,18 @@ namespace Jellyfin.Server
                         }
                         else if (builderContext.HostingEnvironment.IsDevelopment())
                         {
-                            options.ListenAnyIP(appHost.HttpsPort, listenOptions =>
+                            try
                             {
-                                listenOptions.UseHttps();
-                                listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
-                            });
+                                options.ListenAnyIP(appHost.HttpsPort, listenOptions =>
+                                {
+                                    listenOptions.UseHttps();
+                                    listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
+                                });
+                            }
+                            catch (InvalidOperationException ex)
+                            {
+                                _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
+                            }
                         }
                     }
                 })
@@ -496,7 +528,9 @@ namespace Jellyfin.Server
         /// Initialize the logging configuration file using the bundled resource file as a default if it doesn't exist
         /// already.
         /// </summary>
-        private static async Task InitLoggingConfigFile(IApplicationPaths appPaths)
+        /// <param name="appPaths">The application paths.</param>
+        /// <returns>A task representing the creation of the configuration file, or a completed task if the file already exists.</returns>
+        public static async Task InitLoggingConfigFile(IApplicationPaths appPaths)
         {
             // Do nothing if the config file already exists
             string configPath = Path.Combine(appPaths.ConfigurationDirectoryPath, LoggingConfigFileDefault);
@@ -516,7 +550,13 @@ namespace Jellyfin.Server
             await resource.CopyToAsync(dst).ConfigureAwait(false);
         }
 
-        private static IConfiguration CreateAppConfiguration(StartupOptions commandLineOpts, IApplicationPaths appPaths)
+        /// <summary>
+        /// Create the application configuration.
+        /// </summary>
+        /// <param name="commandLineOpts">The command line options passed to the program.</param>
+        /// <param name="appPaths">The application paths.</param>
+        /// <returns>The application configuration.</returns>
+        public static IConfiguration CreateAppConfiguration(StartupOptions commandLineOpts, IApplicationPaths appPaths)
         {
             return new ConfigurationBuilder()
                 .ConfigureAppConfiguration(commandLineOpts, appPaths)

+ 8 - 5
MediaBrowser.Api/Images/RemoteImageService.cs

@@ -265,17 +265,20 @@ namespace MediaBrowser.Api.Images
             {
                 Url = url,
                 BufferContent = false
-
             }).ConfigureAwait(false);
-            var ext = result.ContentType.Split('/').Last();
+            var ext = result.ContentType.Split('/')[^1];
 
             var fullCachePath = GetFullCachePath(urlHash + "." + ext);
 
             Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
-            using (var stream = result.Content)
+            var stream = result.Content;
+            await using (stream.ConfigureAwait(false))
             {
-                using var filestream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
-                await stream.CopyToAsync(filestream).ConfigureAwait(false);
+                var filestream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
+                await using (filestream.ConfigureAwait(false))
+                {
+                    await stream.CopyToAsync(filestream).ConfigureAwait(false);
+                }
             }
 
             Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));

+ 9 - 5
MediaBrowser.Api/ItemLookupService.cs

@@ -299,22 +299,26 @@ namespace MediaBrowser.Api
         {
             var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false);
 
-            var ext = result.ContentType.Split('/').Last();
+            var ext = result.ContentType.Split('/')[^1];
 
             var fullCachePath = GetFullCachePath(urlHash + "." + ext);
 
             Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
-            using (var stream = result.Content)
+            var stream = result.Content;
+
+            await using (stream.ConfigureAwait(false))
             {
-                using var fileStream = new FileStream(
+                var fileStream = new FileStream(
                     fullCachePath,
                     FileMode.Create,
                     FileAccess.Write,
                     FileShare.Read,
                     IODefaults.FileStreamBufferSize,
                     true);
-
-                await stream.CopyToAsync(fileStream).ConfigureAwait(false);
+                await using (fileStream.ConfigureAwait(false))
+                {
+                    await stream.CopyToAsync(fileStream).ConfigureAwait(false);
+                }
             }
 
             Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));

+ 15 - 11
MediaBrowser.Api/Playback/Hls/BaseHlsService.cs

@@ -209,24 +209,28 @@ namespace MediaBrowser.Api.Playback.Hls
                 try
                 {
                     // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written
-                    using var fileStream = GetPlaylistFileStream(playlist);
-                    using var reader = new StreamReader(fileStream);
-                    var count = 0;
-
-                    while (!reader.EndOfStream)
+                    var fileStream = GetPlaylistFileStream(playlist);
+                    await using (fileStream.ConfigureAwait(false))
                     {
-                        var line = reader.ReadLine();
+                        using var reader = new StreamReader(fileStream);
+                        var count = 0;
 
-                        if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
+                        while (!reader.EndOfStream)
                         {
-                            count++;
-                            if (count >= segmentCount)
+                            var line = await reader.ReadLineAsync().ConfigureAwait(false);
+
+                            if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1)
                             {
-                                Logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist);
-                                return;
+                                count++;
+                                if (count >= segmentCount)
+                                {
+                                    Logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist);
+                                    return;
+                                }
                             }
                         }
                     }
+
                     await Task.Delay(100, cancellationToken).ConfigureAwait(false);
                 }
                 catch (IOException)

+ 189 - 8
MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs

@@ -720,22 +720,203 @@ namespace MediaBrowser.Api.Playback.Hls
             //return state.VideoRequest.VideoBitRate.HasValue;
         }
 
+        /// <summary>
+        /// Get the H.26X level of the output video stream.
+        /// </summary>
+        /// <param name="state">StreamState of the current stream.</param>
+        /// <returns>H.26X level of the output video stream.</returns>
+        private int? GetOutputVideoCodecLevel(StreamState state)
+        {
+            string levelString;
+            if (string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)
+                && state.VideoStream.Level.HasValue)
+            {
+                levelString = state.VideoStream?.Level.ToString();
+            }
+            else
+            {
+                levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec);
+            }
+
+            if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
+            {
+                return parsedLevel;
+            }
+
+            return null;
+        }
+
+        /// <summary>
+        /// Gets a formatted string of the output audio codec, for use in the CODECS field.
+        /// </summary>
+        /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
+        /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
+        /// <param name="state">StreamState of the current stream.</param>
+        /// <returns>Formatted audio codec string.</returns>
+        private string GetPlaylistAudioCodecs(StreamState state)
+        {
+
+            if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
+            {
+                string profile = state.GetRequestedProfiles("aac").FirstOrDefault();
+
+                return HlsCodecStringFactory.GetAACString(profile);
+            }
+            else if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringFactory.GetMP3String();
+            }
+            else if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringFactory.GetAC3String();
+            }
+            else if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+            {
+                return HlsCodecStringFactory.GetEAC3String();
+            }
+
+            return string.Empty;
+        }
+
+        /// <summary>
+        /// Gets a formatted string of the output video codec, for use in the CODECS field.
+        /// </summary>
+        /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
+        /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
+        /// <param name="state">StreamState of the current stream.</param>
+        /// <returns>Formatted video codec string.</returns>
+        private string GetPlaylistVideoCodecs(StreamState state, string codec, int level)
+        {
+            if (level == 0)
+            {
+                // This is 0 when there's no requested H.26X level in the device profile
+                // and the source is not encoded in H.26X
+                Logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist");
+                return string.Empty;
+            }
+
+            if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
+            {
+                string profile = state.GetRequestedProfiles("h264").FirstOrDefault();
+
+                return HlsCodecStringFactory.GetH264String(profile, level);
+            }
+            else if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+            {
+                string profile = state.GetRequestedProfiles("h265").FirstOrDefault();
+
+                return HlsCodecStringFactory.GetH265String(profile, level);
+            }
+
+            return string.Empty;
+        }
+
+        /// <summary>
+        /// Appends a CODECS field containing formatted strings of
+        /// the active streams output video and audio codecs.
+        /// </summary>
+        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+        /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
+        /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
+        /// <param name="builder">StringBuilder to append the field to.</param>
+        /// <param name="state">StreamState of the current stream.</param>
+        private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state)
+        {
+            // Video
+            string videoCodecs = string.Empty;
+            int? videoCodecLevel = GetOutputVideoCodecLevel(state);
+            if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue)
+            {
+                videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
+            }
+
+            // Audio
+            string audioCodecs = string.Empty;
+            if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
+            {
+                audioCodecs = GetPlaylistAudioCodecs(state);
+            }
+
+            StringBuilder codecs = new StringBuilder();
+
+            codecs.Append(videoCodecs)
+                .Append(',')
+                .Append(audioCodecs);
+
+            if (codecs.Length > 1)
+            {
+                builder.Append(",CODECS=\"")
+                    .Append(codecs)
+                    .Append('"');
+            }
+        }
+
+        /// <summary>
+        /// Appends a FRAME-RATE field containing the framerate of the output stream.
+        /// </summary>
+        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+        /// <param name="builder">StringBuilder to append the field to.</param>
+        /// <param name="state">StreamState of the current stream.</param>
+        private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state)
+        {
+            double? framerate = null;
+            if (state.TargetFramerate.HasValue)
+            {
+                framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
+            }
+            else if (state.VideoStream.RealFrameRate.HasValue)
+            {
+                framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
+            }
+
+            if (framerate.HasValue)
+            {
+                builder.Append(",FRAME-RATE=\"")
+                    .Append(framerate.Value)
+                    .Append('"');
+            }
+        }
+
+        /// <summary>
+        /// Appends a RESOLUTION field containing the resolution of the output stream.
+        /// </summary>
+        /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+        /// <param name="builder">StringBuilder to append the field to.</param>
+        /// <param name="state">StreamState of the current stream.</param>
+        private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state)
+        {
+            if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
+            {
+                builder.Append(",RESOLUTION=\"")
+                    .Append(state.OutputWidth.GetValueOrDefault())
+                    .Append('x')
+                    .Append(state.OutputHeight.GetValueOrDefault())
+                    .Append('"');
+            }
+        }
+
         private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string subtitleGroup)
         {
-            var header = "#EXT-X-STREAM-INF:BANDWIDTH=" + bitrate.ToString(CultureInfo.InvariantCulture) + ",AVERAGE-BANDWIDTH=" + bitrate.ToString(CultureInfo.InvariantCulture);
+            builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
+                .Append(bitrate.ToString(CultureInfo.InvariantCulture))
+                .Append(",AVERAGE-BANDWIDTH=")
+                .Append(bitrate.ToString(CultureInfo.InvariantCulture));
 
-            // tvos wants resolution, codecs, framerate
-            //if (state.TargetFramerate.HasValue)
-            //{
-            //    header += string.Format(",FRAME-RATE=\"{0}\"", state.TargetFramerate.Value.ToString(CultureInfo.InvariantCulture));
-            //}
+            AppendPlaylistCodecsField(builder, state);
+
+            AppendPlaylistResolutionField(builder, state);
+
+            AppendPlaylistFramerateField(builder, state);
 
             if (!string.IsNullOrWhiteSpace(subtitleGroup))
             {
-                header += string.Format(",SUBTITLES=\"{0}\"", subtitleGroup);
+                builder.Append(",SUBTITLES=\"")
+                    .Append(subtitleGroup)
+                    .Append('"');
             }
 
-            builder.AppendLine(header);
+            builder.Append(Environment.NewLine);
             builder.AppendLine(url);
         }
 

+ 126 - 0
MediaBrowser.Api/Playback/Hls/HlsCodecStringFactory.cs

@@ -0,0 +1,126 @@
+using System;
+using System.Text;
+
+
+namespace MediaBrowser.Api.Playback
+{
+    /// <summary>
+    /// Get various codec strings for use in HLS playlists.
+    /// </summary>
+    static class HlsCodecStringFactory
+    {
+
+        /// <summary>
+        /// Gets a MP3 codec string.
+        /// </summary>
+        /// <returns>MP3 codec string.</returns>
+        public static string GetMP3String()
+        {
+            return "mp4a.40.34";
+        }
+
+        /// <summary>
+        /// Gets an AAC codec string.
+        /// </summary>
+        /// <param name="profile">AAC profile.</param>
+        /// <returns>AAC codec string.</returns>
+        public static string GetAACString(string profile)
+        {
+            StringBuilder result = new StringBuilder("mp4a", 9);
+
+            if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
+            {
+                result.Append(".40.5");
+            }
+            else
+            {
+                // Default to LC if profile is invalid
+                result.Append(".40.2");
+            }
+
+            return result.ToString();
+        }
+
+        /// <summary>
+        /// Gets a H.264 codec string.
+        /// </summary>
+        /// <param name="profile">H.264 profile.</param>
+        /// <param name="level">H.264 level.</param>
+        /// <returns>H.264 string.</returns>
+        public static string GetH264String(string profile, int level)
+        {
+            StringBuilder result = new StringBuilder("avc1", 11);
+
+            if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase))
+            {
+                result.Append(".6400");
+            }
+            else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase))
+            {
+                result.Append(".4D40");
+            }
+            else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase))
+            {
+                result.Append(".42E0");
+            }
+            else
+            {
+                // Default to constrained baseline if profile is invalid
+                result.Append(".4240");
+            }
+
+            string levelHex = level.ToString("X2");
+            result.Append(levelHex);
+
+            return result.ToString();
+        }
+
+        /// <summary>
+        /// Gets a H.265 codec string.
+        /// </summary>
+        /// <param name="profile">H.265 profile.</param>
+        /// <param name="level">H.265 level.</param>
+        /// <returns>H.265 string.</returns>
+        public static string GetH265String(string profile, int level)
+        {
+            // The h265 syntax is a bit of a mystery at the time this comment was written.
+            // This is what I've found through various sources:
+            // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
+            StringBuilder result = new StringBuilder("hev1", 16);
+
+            if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase))
+            {
+                result.Append(".2.6");
+            }
+            else
+            {
+                // Default to main if profile is invalid
+                result.Append(".1.6");
+            }
+
+            result.Append(".L")
+                .Append(level * 3)
+                .Append(".B0");
+
+            return result.ToString();
+        }
+
+        /// <summary>
+        /// Gets an AC-3 codec string.
+        /// </summary>
+        /// <returns>AC-3 codec string.</returns>
+        public static string GetAC3String()
+        {
+            return "mp4a.a5";
+        }
+
+        /// <summary>
+        /// Gets an E-AC-3 codec string.
+        /// </summary>
+        /// <returns>E-AC-3 codec string.</returns>
+        public static string GetEAC3String()
+        {
+            return "mp4a.a6";
+        }
+    }
+}

+ 27 - 11
MediaBrowser.Api/UserService.cs

@@ -35,7 +35,7 @@ namespace MediaBrowser.Api
     }
 
     [Route("/Users/Public", "GET", Summary = "Gets a list of publicly visible users for display on a login screen.")]
-    public class GetPublicUsers : IReturn<UserDto[]>
+    public class GetPublicUsers : IReturn<PublicUserDto[]>
     {
     }
 
@@ -266,22 +266,38 @@ namespace MediaBrowser.Api
             _authContext = authContext;
         }
 
+        /// <summary>
+        /// Gets the public available Users information
+        /// </summary>
+        /// <param name="request">The request.</param>
+        /// <returns>System.Object.</returns>
         public object Get(GetPublicUsers request)
         {
-            // If the startup wizard hasn't been completed then just return all users
-            if (!ServerConfigurationManager.Configuration.IsStartupWizardCompleted)
+            var result = _userManager
+                .Users
+                .Where(item => !item.Policy.IsDisabled);
+
+            if (ServerConfigurationManager.Configuration.IsStartupWizardCompleted)
             {
-                return Get(new GetUsers
+                var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId;
+                result = result.Where(item => !item.Policy.IsHidden);
+
+                if (!string.IsNullOrWhiteSpace(deviceId))
                 {
-                    IsDisabled = false
-                });
+                    result = result.Where(i => _deviceManager.CanAccessDevice(i, deviceId));
+                }
+
+                if (!_networkManager.IsInLocalNetwork(Request.RemoteIp))
+                {
+                    result = result.Where(i => i.Policy.EnableRemoteAccess);
+                }
             }
 
-            return Get(new GetUsers
-            {
-                IsHidden = false,
-                IsDisabled = false
-            }, true, true);
+            return ToOptimizedResult(result
+                    .OrderBy(u => u.Name)
+                    .Select(i => _userManager.GetPublicUserDto(i, Request.RemoteIp))
+                    .ToArray()
+                );
         }
 
         /// <summary>

+ 8 - 0
MediaBrowser.Controller/Library/IUserManager.cs

@@ -143,6 +143,14 @@ namespace MediaBrowser.Controller.Library
         /// <returns>UserDto.</returns>
         UserDto GetUserDto(User user, string remoteEndPoint = null);
 
+        /// <summary>
+        /// Gets the user public dto.
+        /// </summary>
+        /// <param name="user">Ther user.</param>\
+        /// <param name="remoteEndPoint">The remote end point.</param>
+        /// <returns>A public UserDto, aka a UserDto stripped of personal data.</returns>
+        PublicUserDto GetPublicUserDto(User user, string remoteEndPoint = null);
+
         /// <summary>
         /// Authenticates the user.
         /// </summary>

+ 2 - 2
MediaBrowser.Model/Dlna/CodecProfile.cs

@@ -1,8 +1,8 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Linq;
 using System.Xml.Serialization;
-using MediaBrowser.Model.Extensions;
 
 namespace MediaBrowser.Model.Dlna
 {
@@ -57,7 +57,7 @@ namespace MediaBrowser.Model.Dlna
 
             foreach (var val in codec)
             {
-                if (ListHelper.ContainsIgnoreCase(codecs, val))
+                if (codecs.Contains(val, StringComparer.OrdinalIgnoreCase))
                 {
                     return true;
                 }

+ 2 - 4
MediaBrowser.Model/Dlna/ConditionProcessor.cs

@@ -1,8 +1,8 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Linq;
 using System.Globalization;
-using MediaBrowser.Model.Extensions;
 using MediaBrowser.Model.MediaInfo;
 
 namespace MediaBrowser.Model.Dlna
@@ -167,9 +167,7 @@ namespace MediaBrowser.Model.Dlna
             switch (condition.Condition)
             {
                 case ProfileConditionType.EqualsAny:
-                    {
-                        return ListHelper.ContainsIgnoreCase(expected.Split('|'), currentValue);
-                    }
+                    return expected.Split('|').Contains(currentValue, StringComparer.OrdinalIgnoreCase);
                 case ProfileConditionType.Equals:
                     return string.Equals(currentValue, expected, StringComparison.OrdinalIgnoreCase);
                 case ProfileConditionType.NotEquals:

+ 4 - 4
MediaBrowser.Model/Dlna/ContainerProfile.cs

@@ -1,8 +1,8 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Linq;
 using System.Xml.Serialization;
-using MediaBrowser.Model.Extensions;
 
 namespace MediaBrowser.Model.Dlna
 {
@@ -45,7 +45,7 @@ namespace MediaBrowser.Model.Dlna
         public static bool ContainsContainer(string profileContainers, string inputContainer)
         {
             var isNegativeList = false;
-            if (profileContainers != null && profileContainers.StartsWith("-"))
+            if (profileContainers != null && profileContainers.StartsWith("-", StringComparison.Ordinal))
             {
                 isNegativeList = true;
                 profileContainers = profileContainers.Substring(1);
@@ -72,7 +72,7 @@ namespace MediaBrowser.Model.Dlna
 
                 foreach (var container in allInputContainers)
                 {
-                    if (ListHelper.ContainsIgnoreCase(profileContainers, container))
+                    if (profileContainers.Contains(container, StringComparer.OrdinalIgnoreCase))
                     {
                         return false;
                     }
@@ -86,7 +86,7 @@ namespace MediaBrowser.Model.Dlna
 
                 foreach (var container in allInputContainers)
                 {
-                    if (ListHelper.ContainsIgnoreCase(profileContainers, container))
+                    if (profileContainers.Contains(container, StringComparer.OrdinalIgnoreCase))
                     {
                         return true;
                     }

+ 13 - 12
MediaBrowser.Model/Dlna/DeviceProfile.cs

@@ -1,8 +1,8 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Linq;
 using System.Xml.Serialization;
-using MediaBrowser.Model.Extensions;
 using MediaBrowser.Model.MediaInfo;
 
 namespace MediaBrowser.Model.Dlna
@@ -93,14 +93,14 @@ namespace MediaBrowser.Model.Dlna
 
         public DeviceProfile()
         {
-            DirectPlayProfiles = new DirectPlayProfile[] { };
-            TranscodingProfiles = new TranscodingProfile[] { };
-            ResponseProfiles = new ResponseProfile[] { };
-            CodecProfiles = new CodecProfile[] { };
-            ContainerProfiles = new ContainerProfile[] { };
+            DirectPlayProfiles = Array.Empty<DirectPlayProfile>();
+            TranscodingProfiles = Array.Empty<TranscodingProfile>();
+            ResponseProfiles = Array.Empty<ResponseProfile>();
+            CodecProfiles = Array.Empty<CodecProfile>();
+            ContainerProfiles = Array.Empty<ContainerProfile>();
             SubtitleProfiles = Array.Empty<SubtitleProfile>();
 
-            XmlRootAttributes = new XmlAttribute[] { };
+            XmlRootAttributes = Array.Empty<XmlAttribute>();
 
             SupportedMediaTypes = "Audio,Photo,Video";
             MaxStreamingBitrate = 8000000;
@@ -129,13 +129,14 @@ namespace MediaBrowser.Model.Dlna
                     continue;
                 }
 
-                if (!ListHelper.ContainsIgnoreCase(i.GetAudioCodecs(), audioCodec ?? string.Empty))
+                if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase))
                 {
                     continue;
                 }
 
                 return i;
             }
+
             return null;
         }
 
@@ -155,7 +156,7 @@ namespace MediaBrowser.Model.Dlna
                     continue;
                 }
 
-                if (!ListHelper.ContainsIgnoreCase(i.GetAudioCodecs(), audioCodec ?? string.Empty))
+                if (!i.GetAudioCodecs().Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase))
                 {
                     continue;
                 }
@@ -185,7 +186,7 @@ namespace MediaBrowser.Model.Dlna
                 }
 
                 var audioCodecs = i.GetAudioCodecs();
-                if (audioCodecs.Length > 0 && !ListHelper.ContainsIgnoreCase(audioCodecs, audioCodec ?? string.Empty))
+                if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase))
                 {
                     continue;
                 }
@@ -288,13 +289,13 @@ namespace MediaBrowser.Model.Dlna
                 }
 
                 var audioCodecs = i.GetAudioCodecs();
-                if (audioCodecs.Length > 0 && !ListHelper.ContainsIgnoreCase(audioCodecs, audioCodec ?? string.Empty))
+                if (audioCodecs.Length > 0 && !audioCodecs.Contains(audioCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase))
                 {
                     continue;
                 }
 
                 var videoCodecs = i.GetVideoCodecs();
-                if (videoCodecs.Length > 0 && !ListHelper.ContainsIgnoreCase(videoCodecs, videoCodec ?? string.Empty))
+                if (videoCodecs.Length > 0 && !videoCodecs.Contains(videoCodec ?? string.Empty, StringComparer.OrdinalIgnoreCase))
                 {
                     continue;
                 }

+ 3 - 2
MediaBrowser.Model/Dlna/SubtitleProfile.cs

@@ -1,7 +1,8 @@
 #pragma warning disable CS1591
 
+using System;
+using System.Linq;
 using System.Xml.Serialization;
-using MediaBrowser.Model.Extensions;
 
 namespace MediaBrowser.Model.Dlna
 {
@@ -40,7 +41,7 @@ namespace MediaBrowser.Model.Dlna
             }
 
             var languages = GetLanguages();
-            return languages.Length == 0 || ListHelper.ContainsIgnoreCase(languages, subLanguage);
+            return languages.Length == 0 || languages.Contains(subLanguage, StringComparer.OrdinalIgnoreCase);
         }
     }
 }

+ 48 - 0
MediaBrowser.Model/Dto/PublicUserDto.cs

@@ -0,0 +1,48 @@
+using System;
+
+namespace MediaBrowser.Model.Dto
+{
+    /// <summary>
+    /// Class PublicUserDto. Its goal is to show only public information about a user
+    /// </summary>
+    public class PublicUserDto : IItemDto
+    {
+        /// <summary>
+        /// Gets or sets the name.
+        /// </summary>
+        /// <value>The name.</value>
+        public string Name { get; set; }
+
+        /// <summary>
+        /// Gets or sets the primary image tag.
+        /// </summary>
+        /// <value>The primary image tag.</value>
+        public string PrimaryImageTag { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this instance has password.
+        /// </summary>
+        /// <value><c>true</c> if this instance has password; otherwise, <c>false</c>.</value>
+        public bool HasPassword { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether this instance has configured password.
+        /// Note that in this case this method should not be here, but it is necessary when changing password at the
+        /// first login.
+        /// </summary>
+        /// <value><c>true</c> if this instance has configured password; otherwise, <c>false</c>.</value>
+        public bool HasConfiguredPassword { get; set; }
+
+        /// <summary>
+        /// Gets or sets the primary image aspect ratio.
+        /// </summary>
+        /// <value>The primary image aspect ratio.</value>
+        public double? PrimaryImageAspectRatio { get; set; }
+
+        /// <inheritdoc />
+        public override string ToString()
+        {
+            return Name ?? base.ToString();
+        }
+    }
+}

+ 0 - 27
MediaBrowser.Model/Extensions/ListHelper.cs

@@ -1,27 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-
-namespace MediaBrowser.Model.Extensions
-{
-    // TODO: @bond remove
-    public static class ListHelper
-    {
-        public static bool ContainsIgnoreCase(string[] list, string value)
-        {
-            if (value == null)
-            {
-                throw new ArgumentNullException(nameof(value));
-            }
-
-            foreach (var item in list)
-            {
-                if (string.Equals(item, value, StringComparison.OrdinalIgnoreCase))
-                {
-                    return true;
-                }
-            }
-            return false;
-        }
-    }
-}

+ 67 - 50
MediaBrowser.Model/Net/MimeTypes.cs

@@ -17,115 +17,132 @@ namespace MediaBrowser.Model.Net
         /// </summary>
         private static readonly HashSet<string> _videoFileExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
         {
-            ".mkv",
-            ".m2t",
-            ".m2ts",
+            ".3gp",
+            ".asf",
+            ".avi",
+            ".divx",
+            ".dvr-ms",
+            ".f4v",
+            ".flv",
             ".img",
             ".iso",
+            ".m2t",
+            ".m2ts",
+            ".m2v",
+            ".m4v",
             ".mk3d",
-            ".ts",
-            ".rmvb",
+            ".mkv",
             ".mov",
-            ".avi",
+            ".mp4",
             ".mpg",
             ".mpeg",
-            ".wmv",
-            ".mp4",
-            ".divx",
-            ".dvr-ms",
-            ".wtv",
+            ".mts",
+            ".ogg",
             ".ogm",
             ".ogv",
-            ".asf",
-            ".m4v",
-            ".flv",
-            ".f4v",
-            ".3gp",
+            ".rec",
+            ".ts",
+            ".rmvb",
             ".webm",
-            ".mts",
-            ".m2v",
-            ".rec"
+            ".wmv",
+            ".wtv",
         };
 
         // http://en.wikipedia.org/wiki/Internet_media_type
+        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
+        // http://www.iana.org/assignments/media-types/media-types.xhtml
         // Add more as needed
         private static readonly Dictionary<string, string> _mimeTypeLookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
         {
             // Type application
+            { ".7z", "application/x-7z-compressed" },
+            { ".azw", "application/vnd.amazon.ebook" },
+            { ".azw3", "application/vnd.amazon.ebook" },
             { ".cbz", "application/x-cbz" },
             { ".cbr", "application/epub+zip" },
             { ".eot", "application/vnd.ms-fontobject" },
             { ".epub", "application/epub+zip" },
             { ".js", "application/x-javascript" },
             { ".json", "application/json" },
+            { ".m3u8", "application/x-mpegURL" },
             { ".map", "application/x-javascript" },
+            { ".mobi", "application/x-mobipocket-ebook" },
             { ".pdf", "application/pdf" },
+            { ".rar", "application/vnd.rar" },
+            { ".srt", "application/x-subrip" },
             { ".ttml", "application/ttml+xml" },
-            { ".m3u8", "application/x-mpegURL" },
-            { ".mobi", "application/x-mobipocket-ebook" },
-            { ".xml", "application/xml" },
             { ".wasm", "application/wasm" },
+            { ".xml", "application/xml" },
+            { ".zip", "application/zip" },
 
             // Type image
+            { ".bmp", "image/bmp" },
+            { ".gif", "image/gif" },
+            { ".ico", "image/vnd.microsoft.icon" },
             { ".jpg", "image/jpeg" },
             { ".jpeg", "image/jpeg" },
-            { ".tbn", "image/jpeg" },
             { ".png", "image/png" },
-            { ".gif", "image/gif" },
-            { ".tiff", "image/tiff" },
-            { ".webp", "image/webp" },
-            { ".ico", "image/vnd.microsoft.icon" },
             { ".svg", "image/svg+xml" },
             { ".svgz", "image/svg+xml" },
+            { ".tbn", "image/jpeg" },
+            { ".tif", "image/tiff" },
+            { ".tiff", "image/tiff" },
+            { ".webp", "image/webp" },
 
             // Type font
             { ".ttf" , "font/ttf" },
             { ".woff" , "font/woff" },
+            { ".woff2" , "font/woff2" },
 
             // Type text
             { ".ass", "text/x-ssa" },
             { ".ssa", "text/x-ssa" },
             { ".css", "text/css" },
             { ".csv", "text/csv" },
+            { ".rtf", "text/rtf" },
             { ".txt", "text/plain" },
             { ".vtt", "text/vtt" },
 
             // Type video
-            { ".mpg", "video/mpeg" },
-            { ".ogv", "video/ogg" },
-            { ".mov", "video/quicktime" },
-            { ".webm", "video/webm" },
-            { ".mkv", "video/x-matroska" },
-            { ".wmv", "video/x-ms-wmv" },
-            { ".flv", "video/x-flv" },
-            { ".avi", "video/x-msvideo" },
-            { ".asf", "video/x-ms-asf" },
-            { ".m4v", "video/x-m4v" },
-            { ".m4s", "video/mp4" },
             { ".3gp", "video/3gpp" },
             { ".3g2", "video/3gpp2" },
+            { ".asf", "video/x-ms-asf" },
+            { ".avi", "video/x-msvideo" },
+            { ".flv", "video/x-flv" },
+            { ".mp4", "video/mp4" },
+            { ".m4s", "video/mp4" },
+            { ".m4v", "video/x-m4v" },
+            { ".mpegts", "video/mp2t" },
+            { ".mpg", "video/mpeg" },
+            { ".mkv", "video/x-matroska" },
+            { ".mov", "video/quicktime" },
             { ".mpd", "video/vnd.mpeg.dash.mpd" },
+            { ".ogv", "video/ogg" },
             { ".ts", "video/mp2t" },
-            { ".mpegts", "video/mp2t" },
+            { ".webm", "video/webm" },
+            { ".wmv", "video/x-ms-wmv" },
 
             // Type audio
-            { ".mp3", "audio/mpeg" },
-            { ".m4a", "audio/mp4" },
             { ".aac", "audio/mp4" },
-            { ".webma", "audio/webm" },
-            { ".wav", "audio/wav" },
-            { ".wma", "audio/x-ms-wma" },
-            { ".ogg", "audio/ogg" },
-            { ".oga", "audio/ogg" },
-            { ".opus", "audio/ogg" },
             { ".ac3", "audio/ac3" },
+            { ".ape", "audio/x-ape" },
             { ".dsf", "audio/dsf" },
-            { ".m4b", "audio/m4b" },
-            { ".xsp", "audio/xsp" },
             { ".dsp", "audio/dsp" },
             { ".flac", "audio/flac" },
-            { ".ape", "audio/x-ape" },
+            { ".m4a", "audio/mp4" },
+            { ".m4b", "audio/m4b" },
+            { ".mid", "audio/midi" },
+            { ".midi", "audio/midi" },
+            { ".mp3", "audio/mpeg" },
+            { ".oga", "audio/ogg" },
+            { ".ogg", "audio/ogg" },
+            { ".opus", "audio/ogg" },
+            { ".vorbis", "audio/vorbis" },
+            { ".wav", "audio/wav" },
+            { ".webma", "audio/webm" },
+            { ".wma", "audio/x-ms-wma" },
             { ".wv", "audio/x-wavpack" },
+            { ".xsp", "audio/xsp" },
         };
 
         private static readonly Dictionary<string, string> _extensionLookup = CreateExtensionLookup();

+ 9 - 5
MediaBrowser.Model/Notifications/NotificationOptions.cs

@@ -1,7 +1,7 @@
 #pragma warning disable CS1591
 
 using System;
-using MediaBrowser.Model.Extensions;
+using System.Linq;
 using MediaBrowser.Model.Users;
 
 namespace MediaBrowser.Model.Notifications
@@ -81,8 +81,12 @@ namespace MediaBrowser.Model.Notifications
         {
             foreach (NotificationOption i in Options)
             {
-                if (string.Equals(type, i.Type, StringComparison.OrdinalIgnoreCase)) return i;
+                if (string.Equals(type, i.Type, StringComparison.OrdinalIgnoreCase))
+                {
+                    return i;
+                }
             }
+
             return null;
         }
 
@@ -98,7 +102,7 @@ namespace MediaBrowser.Model.Notifications
             NotificationOption opt = GetOptions(notificationType);
 
             return opt == null ||
-                   !ListHelper.ContainsIgnoreCase(opt.DisabledServices, service);
+                   !opt.DisabledServices.Contains(service, StringComparer.OrdinalIgnoreCase);
         }
 
         public bool IsEnabledToMonitorUser(string type, Guid userId)
@@ -106,7 +110,7 @@ namespace MediaBrowser.Model.Notifications
             NotificationOption opt = GetOptions(type);
 
             return opt != null && opt.Enabled &&
-                   !ListHelper.ContainsIgnoreCase(opt.DisabledMonitorUsers, userId.ToString(""));
+                   !opt.DisabledMonitorUsers.Contains(userId.ToString(""), StringComparer.OrdinalIgnoreCase);
         }
 
         public bool IsEnabledToSendToUser(string type, string userId, UserPolicy userPolicy)
@@ -125,7 +129,7 @@ namespace MediaBrowser.Model.Notifications
                     return true;
                 }
 
-                return ListHelper.ContainsIgnoreCase(opt.SendToUsers, userId);
+                return opt.SendToUsers.Contains(userId, StringComparer.OrdinalIgnoreCase);
             }
 
             return false;

+ 19 - 13
MediaBrowser.sln

@@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00
 # Visual Studio 15
 VisualStudioVersion = 15.0.26730.3
 MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server", "Jellyfin.Server\Jellyfin.Server.csproj", "{07E39F42-A2C6-4B32-AF8C-725F957A73FF}"
+EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Controller", "MediaBrowser.Controller\MediaBrowser.Controller.csproj", "{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}"
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Api", "MediaBrowser.Api\MediaBrowser.Api.csproj", "{4FD51AC5-2C16-4308-A993-C3A84F3B4582}"
@@ -36,35 +38,38 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emby.Naming", "Emby.Naming\
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.MediaEncoding", "MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj", "{960295EE-4AF4-4440-A525-B4C295B01A61}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server", "Jellyfin.Server\Jellyfin.Server.csproj", "{07E39F42-A2C6-4B32-AF8C-725F957A73FF}"
-EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{41093F42-C7CC-4D07-956B-6182CBEDE2EC}"
 	ProjectSection(SolutionItems) = preProject
 		.editorconfig = .editorconfig
+		jellyfin.ruleset = jellyfin.ruleset
 		SharedVersion.cs = SharedVersion.cs
 	EndProjectSection
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia", "Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}"
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}"
+	ProjectSection(SolutionItems) = preProject
+		tests\jellyfin-tests.ruleset = tests\jellyfin-tests.ruleset
+	EndProjectSection
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Common.Tests", "tests\Jellyfin.Common.Tests\Jellyfin.Common.Tests.csproj", "{DF194677-DFD3-42AF-9F75-D44D5A416478}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Common.Tests", "tests\Jellyfin.Common.Tests\Jellyfin.Common.Tests.csproj", "{DF194677-DFD3-42AF-9F75-D44D5A416478}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Tests", "tests\Jellyfin.MediaEncoding.Tests\Jellyfin.MediaEncoding.Tests.csproj", "{28464062-0939-4AA7-9F7B-24DDDA61A7C0}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.MediaEncoding.Tests", "tests\Jellyfin.MediaEncoding.Tests\Jellyfin.MediaEncoding.Tests.csproj", "{28464062-0939-4AA7-9F7B-24DDDA61A7C0}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Naming.Tests", "tests\Jellyfin.Naming.Tests\Jellyfin.Naming.Tests.csproj", "{3998657B-1CCC-49DD-A19F-275DC8495F57}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Naming.Tests", "tests\Jellyfin.Naming.Tests\Jellyfin.Naming.Tests.csproj", "{3998657B-1CCC-49DD-A19F-275DC8495F57}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Api.Tests", "tests\Jellyfin.Api.Tests\Jellyfin.Api.Tests.csproj", "{A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Api.Tests", "tests\Jellyfin.Api.Tests\Jellyfin.Api.Tests.csproj", "{A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Implementations.Tests", "tests\Jellyfin.Server.Implementations.Tests\Jellyfin.Server.Implementations.Tests.csproj", "{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server.Implementations.Tests", "tests\Jellyfin.Server.Implementations.Tests\Jellyfin.Server.Implementations.Tests.csproj", "{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Controller.Tests", "tests\Jellyfin.Controller.Tests\Jellyfin.Controller.Tests.csproj", "{462584F7-5023-4019-9EAC-B98CA458C0A0}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Controller.Tests", "tests\Jellyfin.Controller.Tests\Jellyfin.Controller.Tests.csproj", "{462584F7-5023-4019-9EAC-B98CA458C0A0}"
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Data", "Jellyfin.Data\Jellyfin.Data.csproj", "{F03299F2-469F-40EF-A655-3766F97A5702}"
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Implementations", "Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj", "{22C7DA3A-94F2-4E86-9CE6-86AB02B4F843}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Api.Tests", "tests\MediaBrowser.Api.Tests\MediaBrowser.Api.Tests.csproj", "{7C93C84F-105C-48E5-A878-406FA0A5B296}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -116,10 +121,6 @@ Global
 		{713F42B5-878E-499D-A878-E4C652B1D5E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{713F42B5-878E-499D-A878-E4C652B1D5E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{713F42B5-878E-499D-A878-E4C652B1D5E8}.Release|Any CPU.Build.0 = Release|Any CPU
-		{88AE38DF-19D7-406F-A6A9-09527719A21E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{88AE38DF-19D7-406F-A6A9-09527719A21E}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{88AE38DF-19D7-406F-A6A9-09527719A21E}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{88AE38DF-19D7-406F-A6A9-09527719A21E}.Release|Any CPU.Build.0 = Release|Any CPU
 		{E383961B-9356-4D5D-8233-9A1079D03055}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{E383961B-9356-4D5D-8233-9A1079D03055}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{E383961B-9356-4D5D-8233-9A1079D03055}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -188,6 +189,10 @@ Global
 		{22C7DA3A-94F2-4E86-9CE6-86AB02B4F843}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{22C7DA3A-94F2-4E86-9CE6-86AB02B4F843}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{22C7DA3A-94F2-4E86-9CE6-86AB02B4F843}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7C93C84F-105C-48E5-A878-406FA0A5B296}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{7C93C84F-105C-48E5-A878-406FA0A5B296}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7C93C84F-105C-48E5-A878-406FA0A5B296}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{7C93C84F-105C-48E5-A878-406FA0A5B296}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -220,5 +225,6 @@ Global
 		{A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
 		{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
 		{462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+		{7C93C84F-105C-48E5-A878-406FA0A5B296} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
 	EndGlobalSection
 EndGlobal

+ 0 - 13
tests/Jellyfin.Naming.Tests/Video/BaseVideoTest.cs

@@ -1,13 +0,0 @@
-using Emby.Naming.Common;
-using Emby.Naming.Video;
-
-namespace Jellyfin.Naming.Tests.Video
-{
-    public abstract class BaseVideoTest
-    {
-        private readonly NamingOptions _namingOptions = new NamingOptions();
-
-        protected VideoResolver GetParser()
-            => new VideoResolver(_namingOptions);
-    }
-}

+ 1 - 0
tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs

@@ -46,6 +46,7 @@ namespace Jellyfin.Naming.Tests.Video
         [InlineData("Maximum Ride - 2016 - WEBDL-1080p - x264 AC3.mkv", "Maximum Ride", 2016)]
         // FIXME: [InlineData("Robin Hood [Multi-Subs] [2018].mkv", "Robin Hood", 2018)]
         [InlineData(@"3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again
+        [InlineData("3 days to kill (2005).mkv", "3 days to kill", 2005)]
         public void CleanDateTimeTest(string input, string expectedName, int? expectedYear)
         {
             input = Path.GetFileName(input);

+ 1 - 1
tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs

@@ -5,7 +5,7 @@ using Xunit;
 
 namespace Jellyfin.Naming.Tests.Video
 {
-    public class ExtraTests : BaseVideoTest
+    public class ExtraTests
     {
         private readonly NamingOptions _videoOptions = new NamingOptions();
 

+ 29 - 30
tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs

@@ -4,26 +4,26 @@ using Xunit;
 
 namespace Jellyfin.Naming.Tests.Video
 {
-    public class Format3DTests : BaseVideoTest
+    public class Format3DTests
     {
+        private readonly NamingOptions _namingOptions = new NamingOptions();
+
         [Fact]
         public void TestKodiFormat3D()
         {
-            var options = new NamingOptions();
-
-            Test("Super movie.3d.mp4", false, null, options);
-            Test("Super movie.3d.hsbs.mp4", true, "hsbs", options);
-            Test("Super movie.3d.sbs.mp4", true, "sbs", options);
-            Test("Super movie.3d.htab.mp4", true, "htab", options);
-            Test("Super movie.3d.tab.mp4", true, "tab", options);
-            Test("Super movie 3d hsbs.mp4", true, "hsbs", options);
+            Test("Super movie.3d.mp4", false, null);
+            Test("Super movie.3d.hsbs.mp4", true, "hsbs");
+            Test("Super movie.3d.sbs.mp4", true, "sbs");
+            Test("Super movie.3d.htab.mp4", true, "htab");
+            Test("Super movie.3d.tab.mp4", true, "tab");
+            Test("Super movie 3d hsbs.mp4", true, "hsbs");
         }
 
         [Fact]
         public void Test3DName()
         {
             var result =
-                GetParser().ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv");
+                new VideoResolver(_namingOptions).ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv");
 
             Assert.Equal("hsbs", result.Format3D);
             Assert.Equal("Oblivion", result.Name);
@@ -34,32 +34,31 @@ namespace Jellyfin.Naming.Tests.Video
         {
             // These were introduced for Media Browser 3
             // Kodi conventions are preferred but these still need to be supported
-            var options = new NamingOptions();
 
-            Test("Super movie.3d.mp4", false, null, options);
-            Test("Super movie.3d.hsbs.mp4", true, "hsbs", options);
-            Test("Super movie.3d.sbs.mp4", true, "sbs", options);
-            Test("Super movie.3d.htab.mp4", true, "htab", options);
-            Test("Super movie.3d.tab.mp4", true, "tab", options);
+            Test("Super movie.3d.mp4", false, null);
+            Test("Super movie.3d.hsbs.mp4", true, "hsbs");
+            Test("Super movie.3d.sbs.mp4", true, "sbs");
+            Test("Super movie.3d.htab.mp4", true, "htab");
+            Test("Super movie.3d.tab.mp4", true, "tab");
 
-            Test("Super movie.hsbs.mp4", true, "hsbs", options);
-            Test("Super movie.sbs.mp4", true, "sbs", options);
-            Test("Super movie.htab.mp4", true, "htab", options);
-            Test("Super movie.tab.mp4", true, "tab", options);
-            Test("Super movie.sbs3d.mp4", true, "sbs3d", options);
-            Test("Super movie.3d.mvc.mp4", true, "mvc", options);
+            Test("Super movie.hsbs.mp4", true, "hsbs");
+            Test("Super movie.sbs.mp4", true, "sbs");
+            Test("Super movie.htab.mp4", true, "htab");
+            Test("Super movie.tab.mp4", true, "tab");
+            Test("Super movie.sbs3d.mp4", true, "sbs3d");
+            Test("Super movie.3d.mvc.mp4", true, "mvc");
 
-            Test("Super movie [3d].mp4", false, null, options);
-            Test("Super movie [hsbs].mp4", true, "hsbs", options);
-            Test("Super movie [fsbs].mp4", true, "fsbs", options);
-            Test("Super movie [ftab].mp4", true, "ftab", options);
-            Test("Super movie [htab].mp4", true, "htab", options);
-            Test("Super movie [sbs3d].mp4", true, "sbs3d", options);
+            Test("Super movie [3d].mp4", false, null);
+            Test("Super movie [hsbs].mp4", true, "hsbs");
+            Test("Super movie [fsbs].mp4", true, "fsbs");
+            Test("Super movie [ftab].mp4", true, "ftab");
+            Test("Super movie [htab].mp4", true, "htab");
+            Test("Super movie [sbs3d].mp4", true, "sbs3d");
         }
 
-        private void Test(string input, bool is3D, string format3D, NamingOptions options)
+        private void Test(string input, bool is3D, string? format3D)
         {
-            var parser = new Format3DParser(options);
+            var parser = new Format3DParser(_namingOptions);
 
             var result = parser.Parse(input);
 

+ 3 - 2
tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs

@@ -8,6 +8,8 @@ namespace Jellyfin.Naming.Tests.Video
 {
     public class MultiVersionTests
     {
+        private readonly NamingOptions _namingOptions = new NamingOptions();
+
         // FIXME
         // [Fact]
         public void TestMultiEdition1()
@@ -430,8 +432,7 @@ namespace Jellyfin.Naming.Tests.Video
 
         private VideoListResolver GetResolver()
         {
-            var options = new NamingOptions();
-            return new VideoListResolver(options);
+            return new VideoListResolver(_namingOptions);
         }
     }
 }

+ 4 - 2
tests/Jellyfin.Naming.Tests/Video/StackTests.cs

@@ -6,8 +6,10 @@ using Xunit;
 
 namespace Jellyfin.Naming.Tests.Video
 {
-    public class StackTests : BaseVideoTest
+    public class StackTests
     {
+        private readonly NamingOptions _namingOptions = new NamingOptions();
+
         [Fact]
         public void TestSimpleStack()
         {
@@ -446,7 +448,7 @@ namespace Jellyfin.Naming.Tests.Video
 
         private StackResolver GetResolver()
         {
-            return new StackResolver(new NamingOptions());
+            return new StackResolver(_namingOptions);
         }
     }
 }

+ 5 - 5
tests/Jellyfin.Naming.Tests/Video/StubTests.cs

@@ -4,8 +4,10 @@ using Xunit;
 
 namespace Jellyfin.Naming.Tests.Video
 {
-    public class StubTests : BaseVideoTest
+    public class StubTests
     {
+        private readonly NamingOptions _namingOptions = new NamingOptions();
+
         [Fact]
         public void TestStubs()
         {
@@ -27,16 +29,14 @@ namespace Jellyfin.Naming.Tests.Video
         public void TestStubName()
         {
             var result =
-                GetParser().ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc");
+                new VideoResolver(_namingOptions).ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc");
 
             Assert.Equal("Oblivion", result.Name);
         }
 
         private void Test(string path, bool isStub, string stubType)
         {
-            var options = new NamingOptions();
-
-            var isStubResult = StubResolver.TryResolveFile(path, options, out var stubTypeResult);
+            var isStubResult = StubResolver.TryResolveFile(path, _namingOptions, out var stubTypeResult);
 
             Assert.Equal(isStub, isStubResult);
 

+ 2 - 2
tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs

@@ -8,6 +8,7 @@ namespace Jellyfin.Naming.Tests.Video
 {
     public class VideoListResolverTests
     {
+        private readonly NamingOptions _namingOptions = new NamingOptions();
         // FIXME
         // [Fact]
         public void TestStackAndExtras()
@@ -450,8 +451,7 @@ namespace Jellyfin.Naming.Tests.Video
 
         private VideoListResolver GetResolver()
         {
-            var options = new NamingOptions();
-            return new VideoListResolver(options);
+            return new VideoListResolver(_namingOptions);
         }
     }
 }

+ 192 - 267
tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs

@@ -1,275 +1,200 @@
-using MediaBrowser.Model.Entities;
+using System.Collections.Generic;
+using Emby.Naming.Common;
+using Emby.Naming.Video;
+using MediaBrowser.Model.Entities;
 using Xunit;
 
 namespace Jellyfin.Naming.Tests.Video
 {
-    public class VideoResolverTests : BaseVideoTest
+    public class VideoResolverTests
     {
-        // FIXME
-        // [Fact]
-        public void TestSimpleFile()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/Brave (2007)/Brave (2006).mkv");
-
-            Assert.Equal(2006, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Equal("Brave", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestSimpleFile2()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv");
-
-            Assert.Equal(1995, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Equal("Bad Boys", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestSimpleFileWithNumericName()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/300 (2007)/300 (2006).mkv");
-
-            Assert.Equal(2006, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Equal("300", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestExtra()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv");
-
-            Assert.Equal(2006, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Equal(ExtraType.Trailer, result.ExtraType);
-            Assert.Equal("Brave (2006)-trailer", result.Name);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestExtraWithNumericName()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/300 (2007)/300 (2006)-trailer.mkv");
-
-            Assert.Equal(2006, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Equal("300 (2006)-trailer", result.Name);
-            Assert.Equal(ExtraType.Trailer, result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestStubFileWithNumericName()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/300 (2007)/300 (2006).bluray.disc");
-
-            Assert.Equal(2006, result.Year);
-            Assert.True(result.IsStub);
-            Assert.Equal("bluray", result.StubType);
-            Assert.False(result.Is3D);
-            Assert.Equal("300", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestStubFile()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/Brave (2007)/Brave (2006).bluray.disc");
-
-            Assert.Equal(2006, result.Year);
-            Assert.True(result.IsStub);
-            Assert.Equal("bluray", result.StubType);
-            Assert.False(result.Is3D);
-            Assert.Equal("Brave", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestExtraStubWithNumericNameNotSupported()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc");
-
-            Assert.Equal(2006, result.Year);
-            Assert.True(result.IsStub);
-            Assert.Equal("bluray", result.StubType);
-            Assert.False(result.Is3D);
-            Assert.Equal("300", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestExtraStubNotSupported()
-        {
-            // Using a stub for an extra is currently not supported
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc");
-
-            Assert.Equal(2006, result.Year);
-            Assert.True(result.IsStub);
-            Assert.Equal("bluray", result.StubType);
-            Assert.False(result.Is3D);
-            Assert.Equal("brave", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void Test3DFileWithNumericName()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv");
-
-            Assert.Equal(2006, result.Year);
-            Assert.False(result.IsStub);
-            Assert.True(result.Is3D);
-            Assert.Equal("sbs", result.Format3D);
-            Assert.Equal("300", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestBad3DFileWithNumericName()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv");
-
-            Assert.Equal(2006, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Equal("300", result.Name);
-            Assert.Null(result.ExtraType);
-            Assert.Null(result.Format3D);
-        }
-
-        // FIXME
-        // [Fact]
-        public void Test3DFile()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv");
-
-            Assert.Equal(2006, result.Year);
-            Assert.False(result.IsStub);
-            Assert.True(result.Is3D);
-            Assert.Equal("sbs", result.Format3D);
-            Assert.Equal("brave", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        [Fact]
-        public void TestNameWithoutDate()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/American Psycho/American.Psycho.mkv");
-
-            Assert.Null(result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Null(result.Format3D);
-            Assert.Equal("American.Psycho", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestCleanDateAndStringsSequence()
-        {
-            var parser = GetParser();
-
-            // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again
-            var result =
-                parser.ResolveFile(@"/server/Movies/3.Days.to.Kill/3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv");
-
-            Assert.Equal(2014, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Null(result.Format3D);
-            Assert.Equal("3.Days.to.Kill", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        // FIXME
-        // [Fact]
-        public void TestCleanDateAndStringsSequence1()
-        {
-            var parser = GetParser();
-
-            // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again
-            var result =
-                parser.ResolveFile(@"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv");
-
-            Assert.Equal(2005, result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Null(result.Format3D);
-            Assert.Equal("3 days to kill", result.Name);
-            Assert.Null(result.ExtraType);
-        }
-
-        [Fact]
-        public void TestFolderNameWithExtension()
-        {
-            var parser = GetParser();
-
-            var result =
-                parser.ResolveFile(@"/server/Movies/7 Psychos.mkv/7 Psychos.mkv");
-
-            Assert.Null(result.Year);
-            Assert.False(result.IsStub);
-            Assert.False(result.Is3D);
-            Assert.Equal("7 Psychos", result.Name);
-            Assert.Null(result.ExtraType);
+        private readonly NamingOptions _namingOptions = new NamingOptions();
+
+        public static IEnumerable<object[]> GetResolveFileTestData()
+        {
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv",
+                    Container = "mkv",
+                    Name = "7 Psychos"
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv",
+                    Container = "mkv",
+                    Name = "3 days to kill",
+                    Year = 2005
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/American Psycho/American.Psycho.mkv",
+                    Container = "mkv",
+                    Name = "American.Psycho",
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv",
+                    Container = "mkv",
+                    Name = "brave",
+                    Year = 2006,
+                    Is3D = true,
+                    Format3D = "sbs",
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv",
+                    Container = "mkv",
+                    Name = "300",
+                    Year = 2006
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv",
+                    Container = "mkv",
+                    Name = "300",
+                    Year = 2006,
+                    Is3D = true,
+                    Format3D = "sbs",
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc",
+                    Container = "disc",
+                    Name = "brave",
+                    Year = 2006,
+                    IsStub = true,
+                    StubType = "bluray",
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc",
+                    Container = "disc",
+                    Name = "300",
+                    Year = 2006,
+                    IsStub = true,
+                    StubType = "bluray",
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/Brave (2007)/Brave (2006).bluray.disc",
+                    Container = "disc",
+                    Name = "Brave",
+                    Year = 2006,
+                    IsStub = true,
+                    StubType = "bluray",
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/300 (2007)/300 (2006).bluray.disc",
+                    Container = "disc",
+                    Name = "300",
+                    Year = 2006,
+                    IsStub = true,
+                    StubType = "bluray",
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv",
+                    Container = "mkv",
+                    Name = "300",
+                    Year = 2006,
+                    ExtraType = ExtraType.Trailer,
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv",
+                    Container = "mkv",
+                    Name = "Brave",
+                    Year = 2006,
+                    ExtraType = ExtraType.Trailer,
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/300 (2007)/300 (2006).mkv",
+                    Container = "mkv",
+                    Name = "300",
+                    Year = 2006
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv",
+                    Container = "mkv",
+                    Name = "Bad Boys",
+                    Year = 1995,
+                }
+            };
+            yield return new object[]
+            {
+                new VideoFileInfo()
+                {
+                    Path = @"/server/Movies/Brave (2007)/Brave (2006).mkv",
+                    Container = "mkv",
+                    Name = "Brave",
+                    Year = 2006,
+                }
+            };
+        }
+
+
+        [Theory]
+        [MemberData(nameof(GetResolveFileTestData))]
+        public void ResolveFile_ValidFileName_Success(VideoFileInfo expectedResult)
+        {
+            var result = new VideoResolver(_namingOptions).ResolveFile(expectedResult.Path);
+
+            Assert.NotNull(result);
+            Assert.Equal(result.Path, expectedResult.Path);
+            Assert.Equal(result.Container, expectedResult.Container);
+            Assert.Equal(result.Name, expectedResult.Name);
+            Assert.Equal(result.Year, expectedResult.Year);
+            Assert.Equal(result.ExtraType, expectedResult.ExtraType);
+            Assert.Equal(result.Format3D, expectedResult.Format3D);
+            Assert.Equal(result.Is3D, expectedResult.Is3D);
+            Assert.Equal(result.IsStub, expectedResult.IsStub);
+            Assert.Equal(result.StubType, expectedResult.StubType);
+            Assert.Equal(result.IsDirectory, expectedResult.IsDirectory);
+            Assert.Equal(result.FileNameWithoutExtension, expectedResult.FileNameWithoutExtension);
         }
     }
 }

+ 49 - 0
tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs

@@ -0,0 +1,49 @@
+using System.Text.Json;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Branding;
+using Xunit;
+
+namespace MediaBrowser.Api.Tests
+{
+    public sealed class BrandingServiceTests : IClassFixture<JellyfinApplicationFactory>
+    {
+        private readonly JellyfinApplicationFactory _factory;
+
+        public BrandingServiceTests(JellyfinApplicationFactory factory)
+        {
+            _factory = factory;
+        }
+
+        [Fact]
+        public async Task GetConfiguration_ReturnsCorrectResponse()
+        {
+            // Arrange
+            var client = _factory.CreateClient();
+
+            // Act
+            var response = await client.GetAsync("/Branding/Configuration");
+
+            // Assert
+            response.EnsureSuccessStatusCode();
+            Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString());
+            var responseBody = await response.Content.ReadAsStreamAsync();
+            _ = await JsonSerializer.DeserializeAsync<BrandingOptions>(responseBody);
+        }
+
+        [Theory]
+        [InlineData("/Branding/Css")]
+        [InlineData("/Branding/Css.css")]
+        public async Task GetCss_ReturnsCorrectResponse(string url)
+        {
+            // Arrange
+            var client = _factory.CreateClient();
+
+            // Act
+            var response = await client.GetAsync(url);
+
+            // Assert
+            response.EnsureSuccessStatusCode();
+            Assert.Equal("text/css", response.Content.Headers.ContentType.ToString());
+        }
+    }
+}

+ 120 - 0
tests/MediaBrowser.Api.Tests/JellyfinApplicationFactory.cs

@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using Emby.Server.Implementations;
+using Emby.Server.Implementations.IO;
+using Emby.Server.Implementations.Networking;
+using Jellyfin.Drawing.Skia;
+using Jellyfin.Server;
+using MediaBrowser.Common;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Serilog;
+using Serilog.Extensions.Logging;
+
+namespace MediaBrowser.Api.Tests
+{
+    /// <summary>
+    /// Factory for bootstrapping the Jellyfin application in memory for functional end to end tests.
+    /// </summary>
+    public class JellyfinApplicationFactory : WebApplicationFactory<Startup>
+    {
+        private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data");
+        private static readonly ConcurrentBag<IDisposable> _disposableComponents = new ConcurrentBag<IDisposable>();
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class.
+        /// </summary>
+        public JellyfinApplicationFactory()
+        {
+            // Perform static initialization that only needs to happen once per test-run
+            Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger();
+            Program.PerformStaticInitialization();
+        }
+
+        /// <inheritdoc/>
+        protected override IWebHostBuilder CreateWebHostBuilder()
+        {
+            return new WebHostBuilder();
+        }
+
+        /// <inheritdoc/>
+        protected override void ConfigureWebHost(IWebHostBuilder builder)
+        {
+            // Specify the startup command line options
+            var commandLineOpts = new StartupOptions
+            {
+                NoWebClient = true,
+                NoAutoRunWebApp = true
+            };
+
+            // Use a temporary directory for the application paths
+            var webHostPathRoot = Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
+            Directory.CreateDirectory(Path.Combine(webHostPathRoot, "logs"));
+            Directory.CreateDirectory(Path.Combine(webHostPathRoot, "config"));
+            Directory.CreateDirectory(Path.Combine(webHostPathRoot, "cache"));
+            Directory.CreateDirectory(Path.Combine(webHostPathRoot, "jellyfin-web"));
+            var appPaths = new ServerApplicationPaths(
+                webHostPathRoot,
+                Path.Combine(webHostPathRoot, "logs"),
+                Path.Combine(webHostPathRoot, "config"),
+                Path.Combine(webHostPathRoot, "cache"),
+                Path.Combine(webHostPathRoot, "jellyfin-web"));
+
+            // Create the logging config file
+            // TODO: We shouldn't need to do this since we are only logging to console
+            Program.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult();
+
+            // Create a copy of the application configuration to use for startup
+            var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths);
+
+            ILoggerFactory loggerFactory = new SerilogLoggerFactory();
+            _disposableComponents.Add(loggerFactory);
+
+            // Create the app host and initialize it
+            var appHost = new CoreAppHost(
+                appPaths,
+                loggerFactory,
+                commandLineOpts,
+                new ManagedFileSystem(loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths),
+                new NetworkManager(loggerFactory.CreateLogger<NetworkManager>()));
+            _disposableComponents.Add(appHost);
+            var serviceCollection = new ServiceCollection();
+            appHost.Init(serviceCollection);
+
+            // Configure the web host builder
+            Program.ConfigureWebHostBuilder(builder, appHost, serviceCollection, commandLineOpts, startupConfig, appPaths);
+        }
+
+        /// <inheritdoc/>
+        protected override TestServer CreateServer(IWebHostBuilder builder)
+        {
+            // Create the test server using the base implementation
+            var testServer = base.CreateServer(builder);
+
+            // Finish initializing the app host
+            var appHost = (CoreAppHost)testServer.Services.GetRequiredService<IApplicationHost>();
+            appHost.ServiceProvider = testServer.Services;
+            appHost.InitializeServices().GetAwaiter().GetResult();
+            appHost.RunStartupTasksAsync().GetAwaiter().GetResult();
+
+            return testServer;
+        }
+
+        /// <inheritdoc/>
+        protected override void Dispose(bool disposing)
+        {
+            foreach (var disposable in _disposableComponents)
+            {
+                disposable.Dispose();
+            }
+
+            _disposableComponents.Clear();
+
+            base.Dispose(disposing);
+        }
+    }
+}

+ 33 - 0
tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj

@@ -0,0 +1,33 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.1</TargetFramework>
+    <IsPackable>false</IsPackable>
+    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.3" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
+    <PackageReference Include="xunit" Version="2.4.1" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
+    <PackageReference Include="coverlet.collector" Version="1.2.1" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\..\Jellyfin.Server\Jellyfin.Server.csproj" />
+    <ProjectReference Include="..\..\MediaBrowser.Api\MediaBrowser.Api.csproj" />
+  </ItemGroup>
+
+  <!-- Code Analyzers-->
+  <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
+    <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
+    <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
+  </ItemGroup>
+
+  <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+    <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet>
+  </PropertyGroup>
+
+</Project>

+ 22 - 0
tests/jellyfin-tests.ruleset

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RuleSet Name="Rules for MediaBrowser.Api.Tests" Description="Code analysis rules for MediaBrowser.Api.Tests.csproj" ToolsVersion="14.0">
+
+  <!-- Include the solution default RuleSet. The rules in this file will override the defaults. -->
+  <Include Path="../jellyfin.ruleset" Action="Default" />
+
+  <!-- StyleCop Analyzer Rules -->
+  <Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers">
+    <!-- SA0001: XML comment analysis is disabled due to project configuration -->
+    <Rule Id="SA0001" Action="None" />
+  </Rules>
+
+  <!-- FxCop Analyzer Rules -->
+  <Rules AnalyzerId="Microsoft.CodeAnalysis.FxCopAnalyzers" RuleNamespace="Microsoft.Design">
+    <!-- CA1707: Identifiers should not contain underscores -->
+    <Rule Id="CA1707" Action="None" />
+    <!-- CA2007: Consider calling ConfigureAwait on the awaited task -->
+    <Rule Id="CA2007" Action="None" />
+    <!-- CA2234: Pass system uri objects instead of strings -->
+    <Rule Id="CA2234" Action="Info" />      
+  </Rules>
+</RuleSet>