diff options
187 files changed, 2465 insertions, 1282 deletions
diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 47477ba60..0a63b329b 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -22,6 +22,12 @@ jobs: BuildConfiguration: ubuntu.armhf Linux.amd64: BuildConfiguration: linux.amd64 + Linux.amd64-musl: + BuildConfiguration: linux.amd64-musl + Linux.arm64: + BuildConfiguration: linux.arm64 + Linux.armhf: + BuildConfiguration: linux.armhf Windows.amd64: BuildConfiguration: windows.amd64 MacOS: @@ -204,6 +210,7 @@ jobs: - task: DotNetCoreCLI@2 displayName: 'Build Unstable Nuget packages' + condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') inputs: command: 'custom' projects: | diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index fedd20b68..21ba1c755 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -7,12 +7,14 @@ using System.IO; using System.Linq; using System.Reflection; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Emby.Dlna.Profiles; using Emby.Dlna.Server; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Controller; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; @@ -32,9 +34,9 @@ namespace Emby.Dlna private readonly IXmlSerializer _xmlSerializer; private readonly IFileSystem _fileSystem; private readonly ILogger<DlnaManager> _logger; - private readonly IJsonSerializer _jsonSerializer; private readonly IServerApplicationHost _appHost; private static readonly Assembly _assembly = typeof(DlnaManager).Assembly; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); private readonly Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>> _profiles = new Dictionary<string, Tuple<InternalProfileInfo, DeviceProfile>>(StringComparer.Ordinal); @@ -43,14 +45,12 @@ namespace Emby.Dlna IFileSystem fileSystem, IApplicationPaths appPaths, ILoggerFactory loggerFactory, - IJsonSerializer jsonSerializer, IServerApplicationHost appHost) { _xmlSerializer = xmlSerializer; _fileSystem = fileSystem; _appPaths = appPaths; _logger = loggerFactory.CreateLogger<DlnaManager>(); - _jsonSerializer = jsonSerializer; _appHost = appHost; } @@ -495,9 +495,9 @@ namespace Emby.Dlna return profile; } - var json = _jsonSerializer.SerializeToString(profile); + var json = JsonSerializer.Serialize(profile, _jsonOptions); - return _jsonSerializer.DeserializeFromString<DeviceProfile>(json); + return JsonSerializer.Deserialize<DeviceProfile>(json, _jsonOptions); } public string GetServerDescriptionXml(IHeaderDictionary headers, string serverUuId, string serverAddress) diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index bd30cc1e1..8b057a095 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -78,9 +78,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" /> - <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> </ItemGroup> </Project> diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index ced56a718..3f7b558f6 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -136,6 +136,11 @@ namespace Emby.Dlna.Main public static DlnaEntryPoint Current { get; private set; } + /// <summary> + /// Gets a value indicating whether the dlna server is enabled. + /// </summary> + public static bool Enabled { get; private set; } + public IContentDirectory ContentDirectory { get; private set; } public IConnectionManager ConnectionManager { get; private set; } @@ -168,6 +173,7 @@ namespace Emby.Dlna.Main private void ReloadComponents() { var options = _config.GetDlnaConfiguration(); + Enabled = options.EnableServer; StartSsdpHandler(); diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index b7cd91a5c..486109304 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -340,10 +340,19 @@ namespace Emby.Dlna.PlayTo } var playlist = new PlaylistItem[len]; - playlist[0] = CreatePlaylistItem(items[0], user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex); + + // Not nullable enabled - so this is required. + playlist[0] = CreatePlaylistItem( + items[0], + user, + command.StartPositionTicks ?? 0, + command.MediaSourceId ?? string.Empty, + command.AudioStreamIndex, + command.SubtitleStreamIndex); + for (int i = 1; i < len; i++) { - playlist[i] = CreatePlaylistItem(items[i], user, 0, null, null, null); + playlist[i] = CreatePlaylistItem(items[i], user, 0, string.Empty, null, null); } _logger.LogDebug("{0} - Playlist created", _session.DeviceName); diff --git a/Emby.Dlna/Profiles/SonyPs3Profile.cs b/Emby.Dlna/Profiles/SonyPs3Profile.cs index d56b1df50..e4a7a3a59 100644 --- a/Emby.Dlna/Profiles/SonyPs3Profile.cs +++ b/Emby.Dlna/Profiles/SonyPs3Profile.cs @@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles Container = "ts,mpegts", Type = DlnaProfileType.Video, VideoCodec = "mpeg1video,mpeg2video,h264", - AudioCodec = "ac3,mp2,mp3,aac" + AudioCodec = "aac,ac3,mp2" }, new DirectPlayProfile { @@ -92,7 +92,7 @@ namespace Emby.Dlna.Profiles { Container = "ts", VideoCodec = "h264", - AudioCodec = "ac3,aac,mp3", + AudioCodec = "aac,ac3,mp2", Type = DlnaProfileType.Video }, new TranscodingProfile diff --git a/Emby.Dlna/Profiles/SonyPs4Profile.cs b/Emby.Dlna/Profiles/SonyPs4Profile.cs index db56094e2..985df0c9a 100644 --- a/Emby.Dlna/Profiles/SonyPs4Profile.cs +++ b/Emby.Dlna/Profiles/SonyPs4Profile.cs @@ -52,7 +52,7 @@ namespace Emby.Dlna.Profiles Container = "ts,mpegts", Type = DlnaProfileType.Video, VideoCodec = "mpeg1video,mpeg2video,h264", - AudioCodec = "ac3,mp2,mp3,aac" + AudioCodec = "aac,ac3,mp2" }, new DirectPlayProfile { @@ -94,7 +94,7 @@ namespace Emby.Dlna.Profiles { Container = "ts", VideoCodec = "h264", - AudioCodec = "mp3", + AudioCodec = "aac,ac3,mp2", Type = DlnaProfileType.Video }, new TranscodingProfile diff --git a/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml b/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml index bafa44b82..129b188e2 100644 --- a/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml +++ b/Emby.Dlna/Profiles/Xml/Sony PlayStation 3.xml @@ -38,7 +38,7 @@ <XmlRootAttributes /> <DirectPlayProfiles> <DirectPlayProfile container="avi" audioCodec="mp2,mp3" videoCodec="mpeg4" type="Video" /> - <DirectPlayProfile container="ts,mpegts" audioCodec="ac3,mp2,mp3,aac" videoCodec="mpeg1video,mpeg2video,h264" type="Video" /> + <DirectPlayProfile container="ts,mpegts" audioCodec="aac,ac3,mp2" videoCodec="mpeg1video,mpeg2video,h264" type="Video" /> <DirectPlayProfile container="mpeg" audioCodec="mp2" videoCodec="mpeg1video,mpeg2video" type="Video" /> <DirectPlayProfile container="mp4" audioCodec="aac,ac3" videoCodec="h264,mpeg4" type="Video" /> <DirectPlayProfile container="aac,mp3,wav" type="Audio" /> @@ -46,7 +46,7 @@ </DirectPlayProfiles> <TranscodingProfiles> <TranscodingProfile container="mp3" type="Audio" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> - <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="ac3,aac,mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> + <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="aac,ac3,mp2" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> <TranscodingProfile container="jpeg" type="Photo" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> </TranscodingProfiles> <ContainerProfiles> diff --git a/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml b/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml index eb8e645b3..592119305 100644 --- a/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml +++ b/Emby.Dlna/Profiles/Xml/Sony PlayStation 4.xml @@ -38,7 +38,7 @@ <XmlRootAttributes /> <DirectPlayProfiles> <DirectPlayProfile container="avi" audioCodec="mp2,mp3" videoCodec="mpeg4" type="Video" /> - <DirectPlayProfile container="ts,mpegts" audioCodec="ac3,mp2,mp3,aac" videoCodec="mpeg1video,mpeg2video,h264" type="Video" /> + <DirectPlayProfile container="ts,mpegts" audioCodec="aac,ac3,mp2" videoCodec="mpeg1video,mpeg2video,h264" type="Video" /> <DirectPlayProfile container="mpeg" audioCodec="mp2" videoCodec="mpeg1video,mpeg2video" type="Video" /> <DirectPlayProfile container="mp4,mkv,m4v" audioCodec="aac,ac3" videoCodec="h264,mpeg4" type="Video" /> <DirectPlayProfile container="aac,mp3,wav" type="Audio" /> @@ -46,7 +46,7 @@ </DirectPlayProfiles> <TranscodingProfiles> <TranscodingProfile container="mp3" type="Audio" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Bytes" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> - <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="mp3" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> + <TranscodingProfile container="ts" type="Video" videoCodec="h264" audioCodec="aac,ac3,mp2" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> <TranscodingProfile container="jpeg" type="Photo" estimateContentLength="false" enableMpegtsM2TsMode="false" transcodeSeekInfo="Auto" copyTimestamps="false" context="Streaming" enableSubtitlesInManifest="false" minSegments="0" segmentLength="0" breakOnNonKeyFrames="false" /> </TranscodingProfiles> <ContainerProfiles> diff --git a/Emby.Dlna/Service/BaseControlHandler.cs b/Emby.Dlna/Service/BaseControlHandler.cs index 198852ec1..8d2486fee 100644 --- a/Emby.Dlna/Service/BaseControlHandler.cs +++ b/Emby.Dlna/Service/BaseControlHandler.cs @@ -49,7 +49,7 @@ namespace Emby.Dlna.Service { ControlRequestInfo requestInfo = null; - using (var streamReader = new StreamReader(request.InputXml)) + using (var streamReader = new StreamReader(request.InputXml, Encoding.UTF8)) { var readerSettings = new XmlReaderSettings() { diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index d74ea0352..8fa712914 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -3,14 +3,15 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Net; -using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Emby.Dlna; @@ -49,6 +50,7 @@ using Jellyfin.Networking.Manager; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; @@ -117,12 +119,12 @@ namespace Emby.Server.Implementations private readonly IFileSystem _fileSystemManager; private readonly IXmlSerializer _xmlSerializer; - private readonly IJsonSerializer _jsonSerializer; private readonly IStartupOptions _startupOptions; private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; private string[] _urlPrefixes; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); /// <summary> /// Gets a value indicating whether this instance can self restart. @@ -256,7 +258,6 @@ namespace Emby.Server.Implementations IServiceCollection serviceCollection) { _xmlSerializer = new MyXmlSerializer(); - _jsonSerializer = new JsonSerializer(); ServiceCollection = serviceCollection; @@ -284,13 +285,6 @@ namespace Emby.Server.Implementations fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); - CertificateInfo = new CertificateInfo - { - Path = ServerConfigurationManager.Configuration.CertificatePath, - Password = ServerConfigurationManager.Configuration.CertificatePassword - }; - Certificate = GetCertificate(CertificateInfo); - ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; ApplicationVersionString = ApplicationVersion.ToString(3); ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; @@ -456,6 +450,7 @@ namespace Emby.Server.Implementations Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; + ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated; _mediaEncoder.SetFFmpegPath(); @@ -505,6 +500,13 @@ namespace Emby.Server.Implementations HttpsPort = NetworkConfiguration.DefaultHttpsPort; } + CertificateInfo = new CertificateInfo + { + Path = networkConfiguration.CertificatePath, + Password = networkConfiguration.CertificatePassword + }; + Certificate = GetCertificate(CertificateInfo); + DiscoverTypes(); RegisterServices(); @@ -526,8 +528,6 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>(); - ServiceCollection.AddSingleton(_fileSystemManager); ServiceCollection.AddSingleton<TmdbClientManager>(); @@ -714,7 +714,7 @@ namespace Emby.Server.Implementations // Don't use an empty string password var password = string.IsNullOrWhiteSpace(info.Password) ? null : info.Password; - var localCert = new X509Certificate2(certificateLocation, password); + var localCert = new X509Certificate2(certificateLocation, password, X509KeyStorageFlags.UserKeySet); // localCert.PrivateKey = PrivateKey.CreateFromFile(pvk_file).RSA; if (!localCert.HasPrivateKey) { @@ -752,7 +752,6 @@ namespace Emby.Server.Implementations UserView.CollectionManager = Resolve<ICollectionManager>(); BaseItem.MediaSourceManager = Resolve<IMediaSourceManager>(); CollectionFolder.XmlSerializer = _xmlSerializer; - CollectionFolder.JsonSerializer = Resolve<IJsonSerializer>(); CollectionFolder.ApplicationHost = this; } @@ -912,11 +911,11 @@ namespace Emby.Server.Implementations protected void OnConfigurationUpdated(object sender, EventArgs e) { var requiresRestart = false; + var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration(); // Don't do anything if these haven't been set yet if (HttpPort != 0 && HttpsPort != 0) { - var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration(); // Need to restart if ports have changed if (networkConfiguration.HttpServerPortNumber != HttpPort || networkConfiguration.HttpsPortNumber != HttpsPort) @@ -936,10 +935,7 @@ namespace Emby.Server.Implementations requiresRestart = true; } - var currentCertPath = CertificateInfo?.Path; - var newCertPath = ServerConfigurationManager.Configuration.CertificatePath; - - if (!string.Equals(currentCertPath, newCertPath, StringComparison.OrdinalIgnoreCase)) + if (ValidateSslCertificate(networkConfiguration)) { requiresRestart = true; } @@ -953,6 +949,33 @@ namespace Emby.Server.Implementations } /// <summary> + /// Validates the SSL certificate. + /// </summary> + /// <param name="networkConfig">The new configuration.</param> + /// <exception cref="FileNotFoundException">The certificate path doesn't exist.</exception> + private bool ValidateSslCertificate(NetworkConfiguration networkConfig) + { + var newPath = networkConfig.CertificatePath; + + if (!string.IsNullOrWhiteSpace(newPath) + && !string.Equals(CertificateInfo?.Path, newPath, StringComparison.Ordinal)) + { + if (File.Exists(newPath)) + { + return true; + } + + throw new FileNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "Certificate file '{0}' does not exist.", + newPath)); + } + + return false; + } + + /// <summary> /// Notifies that the kernel that a change has been made that requires a restart. /// </summary> public void NotifyPendingRestart() @@ -1025,7 +1048,8 @@ namespace Emby.Server.Implementations var metafile = Path.Combine(dir, "meta.json"); if (File.Exists(metafile)) { - var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile); + var jsonString = File.ReadAllText(metafile, Encoding.UTF8); + var manifest = JsonSerializer.Deserialize<PluginManifest>(jsonString, _jsonOptions); if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) { diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 57684a429..2d5b19fa6 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -3,11 +3,14 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -21,7 +24,6 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; @@ -44,10 +46,10 @@ namespace Emby.Server.Implementations.Channels private readonly ILogger<ChannelManager> _logger; private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; - private readonly IJsonSerializer _jsonSerializer; private readonly IProviderManager _providerManager; private readonly IMemoryCache _memoryCache; private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); /// <summary> /// Initializes a new instance of the <see cref="ChannelManager"/> class. @@ -59,7 +61,6 @@ namespace Emby.Server.Implementations.Channels /// <param name="config">The server configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> /// <param name="userDataManager">The user data manager.</param> - /// <param name="jsonSerializer">The JSON serializer.</param> /// <param name="providerManager">The provider manager.</param> /// <param name="memoryCache">The memory cache.</param> public ChannelManager( @@ -70,7 +71,6 @@ namespace Emby.Server.Implementations.Channels IServerConfigurationManager config, IFileSystem fileSystem, IUserDataManager userDataManager, - IJsonSerializer jsonSerializer, IProviderManager providerManager, IMemoryCache memoryCache) { @@ -81,7 +81,6 @@ namespace Emby.Server.Implementations.Channels _config = config; _fileSystem = fileSystem; _userDataManager = userDataManager; - _jsonSerializer = jsonSerializer; _providerManager = providerManager; _memoryCache = memoryCache; } @@ -343,7 +342,9 @@ namespace Emby.Server.Implementations.Channels try { - return _jsonSerializer.DeserializeFromFile<List<MediaSourceInfo>>(path) ?? new List<MediaSourceInfo>(); + var jsonString = File.ReadAllText(path, Encoding.UTF8); + return JsonSerializer.Deserialize<List<MediaSourceInfo>>(jsonString, _jsonOptions) + ?? new List<MediaSourceInfo>(); } catch { @@ -351,7 +352,7 @@ namespace Emby.Server.Implementations.Channels } } - private void SaveMediaSources(BaseItem item, List<MediaSourceInfo> mediaSources) + private async Task SaveMediaSources(BaseItem item, List<MediaSourceInfo> mediaSources) { var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); @@ -370,7 +371,8 @@ namespace Emby.Server.Implementations.Channels Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(mediaSources, path); + await using FileStream createStream = File.Create(path); + await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false); } /// <inheritdoc /> @@ -812,7 +814,8 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath); + await using FileStream jsonStream = File.OpenRead(cachePath); + var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { return null; @@ -834,7 +837,8 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath); + await using FileStream jsonStream = File.OpenRead(cachePath); + var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { return null; @@ -865,7 +869,7 @@ namespace Emby.Server.Implementations.Channels throw new InvalidOperationException("Channel returned a null result from GetChannelItems"); } - CacheResponse(result, cachePath); + await CacheResponse(result, cachePath); return result; } @@ -875,13 +879,14 @@ namespace Emby.Server.Implementations.Channels } } - private void CacheResponse(object result, string path) + private async Task CacheResponse(object result, string path) { try { Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(result, path); + await using FileStream createStream = File.Create(path); + await JsonSerializer.SerializeAsync(createStream, result, _jsonOptions).ConfigureAwait(false); } catch (Exception ex) { @@ -1176,11 +1181,11 @@ namespace Emby.Server.Implementations.Channels { if (enableMediaProbe && !info.IsLiveStream && item.HasPathProtocol) { - SaveMediaSources(item, new List<MediaSourceInfo>()); + await SaveMediaSources(item, new List<MediaSourceInfo>()).ConfigureAwait(false); } else { - SaveMediaSources(item, info.MediaSources); + await SaveMediaSources(item, info.MediaSources).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs index f05a30a89..7a8ed8c29 100644 --- a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -88,7 +88,6 @@ namespace Emby.Server.Implementations.Configuration var newConfig = (ServerConfiguration)newConfiguration; ValidateMetadataPath(newConfig); - ValidateSslCertificate(newConfig); ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig)); @@ -96,31 +95,6 @@ namespace Emby.Server.Implementations.Configuration } /// <summary> - /// Validates the SSL certificate. - /// </summary> - /// <param name="newConfig">The new configuration.</param> - /// <exception cref="FileNotFoundException">The certificate path doesn't exist.</exception> - private void ValidateSslCertificate(BaseApplicationConfiguration newConfig) - { - var serverConfig = (ServerConfiguration)newConfig; - - var newPath = serverConfig.CertificatePath; - - if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(Configuration.CertificatePath, newPath, StringComparison.Ordinal)) - { - if (!File.Exists(newPath)) - { - throw new FileNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "Certificate file '{0}' does not exist.", - newPath)); - } - } - } - - /// <summary> /// Validates the metadata path. /// </summary> /// <param name="newConfig">The new configuration.</param> diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index f3e3a6397..686944a28 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1138,7 +1138,10 @@ namespace Emby.Server.Implementations.Dto if (episodeSeries != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary); - AttachPrimaryImageAspectRatio(dto, episodeSeries); + if (!dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, episodeSeries); + } } } @@ -1185,7 +1188,10 @@ namespace Emby.Server.Implementations.Dto if (series != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary); - AttachPrimaryImageAspectRatio(dto, series); + if (!dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, series); + } } } } diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 91c4648c6..1e54c3b33 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -23,21 +23,12 @@ <ItemGroup> <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" /> - <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" /> - <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" /> - <PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" /> <PackageReference Include="Mono.Nat" Version="3.0.1" /> <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.1" /> - <PackageReference Include="ServiceStack.Text.Core" Version="5.10.2" /> <PackageReference Include="sharpcompress" Version="0.26.0" /> <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" /> <PackageReference Include="DotNet.Glob" Version="3.1.0" /> diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index d62e2eefe..024404ceb 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -185,11 +185,11 @@ namespace Emby.Server.Implementations.HttpServer.Security updateToken = true; } - authInfo.IsApiKey = true; + authInfo.IsApiKey = false; } else { - authInfo.IsApiKey = false; + authInfo.IsApiKey = true; } if (updateToken) diff --git a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs index 86914dea2..040b6b9e4 100644 --- a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs @@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.HttpServer.Security public User GetUser(object requestContext) { - return GetUser((HttpContext)requestContext); + return GetUser(((HttpRequest)requestContext).HttpContext); } } } diff --git a/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs b/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs deleted file mode 100644 index d4e790c9a..000000000 --- a/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Events; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Net; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Library -{ - /// <summary> - /// A library post scan/refresh task for pre-fetching remote images. - /// </summary> - public class ImageFetcherPostScanTask : ILibraryPostScanTask - { - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly ILogger<ImageFetcherPostScanTask> _logger; - private readonly SemaphoreSlim _imageFetcherLock; - - private ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)> _queuedItems; - - /// <summary> - /// Initializes a new instance of the <see cref="ImageFetcherPostScanTask"/> class. - /// </summary> - /// <param name="libraryManager">An instance of <see cref="ILibraryManager"/>.</param> - /// <param name="providerManager">An instance of <see cref="IProviderManager"/>.</param> - /// <param name="logger">An instance of <see cref="ILogger{ImageFetcherPostScanTask}"/>.</param> - public ImageFetcherPostScanTask( - ILibraryManager libraryManager, - IProviderManager providerManager, - ILogger<ImageFetcherPostScanTask> logger) - { - _libraryManager = libraryManager; - _providerManager = providerManager; - _logger = logger; - _queuedItems = new ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)>(); - _imageFetcherLock = new SemaphoreSlim(1, 1); - _libraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated; - _libraryManager.ItemUpdated += OnLibraryManagerItemAddedOrUpdated; - _providerManager.RefreshCompleted += OnProviderManagerRefreshCompleted; - } - - /// <inheritdoc /> - public async Task Run(IProgress<double> progress, CancellationToken cancellationToken) - { - // Sometimes a library scan will cause this to run twice if there's an item refresh going on. - await _imageFetcherLock.WaitAsync(cancellationToken).ConfigureAwait(false); - - try - { - var now = DateTime.UtcNow; - var itemGuids = _queuedItems.Keys.ToList(); - - for (var i = 0; i < itemGuids.Count; i++) - { - if (!_queuedItems.TryGetValue(itemGuids[i], out var queuedItem)) - { - continue; - } - - var itemId = queuedItem.item.Id.ToString("N", CultureInfo.InvariantCulture); - var itemType = queuedItem.item.GetType(); - _logger.LogDebug( - "Updating remote images for item {ItemId} with media type {ItemMediaType}", - itemId, - itemType); - try - { - await _libraryManager.UpdateImagesAsync(queuedItem.item, queuedItem.updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to fetch images for {Type} item with id {ItemId}", itemType, itemId); - } - - _queuedItems.TryRemove(queuedItem.item.Id, out _); - } - - if (itemGuids.Count > 0) - { - _logger.LogInformation( - "Finished updating/pre-fetching {NumberOfImages} images. Elapsed time: {TimeElapsed}s.", - itemGuids.Count.ToString(CultureInfo.InvariantCulture), - (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture)); - } - else - { - _logger.LogDebug("No images were updated."); - } - } - finally - { - _imageFetcherLock.Release(); - } - } - - private void OnLibraryManagerItemAddedOrUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs) - { - if (!_queuedItems.ContainsKey(itemChangeEventArgs.Item.Id) && itemChangeEventArgs.Item.ImageInfos.Length > 0) - { - _queuedItems.AddOrUpdate( - itemChangeEventArgs.Item.Id, - (itemChangeEventArgs.Item, itemChangeEventArgs.UpdateReason), - (key, existingValue) => existingValue); - } - } - - private void OnProviderManagerRefreshCompleted(object sender, GenericEventArgs<BaseItem> e) - { - if (!_queuedItems.ContainsKey(e.Argument.Id) && e.Argument.ImageInfos.Length > 0) - { - _queuedItems.AddOrUpdate( - e.Argument.Id, - (e.Argument, ItemUpdateType.None), - (key, existingValue) => existingValue); - } - - // The RefreshCompleted event is a bit awkward in that it seems to _only_ be fired on - // the item that was refreshed regardless of children refreshes. So we take it as a signal - // that the refresh is entirely completed. - Run(null, CancellationToken.None).GetAwaiter().GetResult(); - } - } -} diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 5b926b0f4..db27862ce 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -42,7 +42,6 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; -using MediaBrowser.Model.Net; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.MediaInfo; @@ -1955,9 +1954,12 @@ namespace Emby.Server.Implementations.Library } /// <inheritdoc /> - public Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) + public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) { - RunMetadataSavers(items, updateReason); + foreach (var item in items) + { + await RunMetadataSavers(item, updateReason).ConfigureAwait(false); + } _itemRepository.SaveItems(items, cancellationToken); @@ -1988,25 +1990,22 @@ namespace Emby.Server.Implementations.Library } } } - - return Task.CompletedTask; } /// <inheritdoc /> public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken); - public void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason) + public Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) { - foreach (var item in items) + if (item.IsFileProtocol) { - if (item.IsFileProtocol) - { - ProviderManager.SaveMetadata(item, updateReason); - } - - item.DateLastSaved = DateTime.UtcNow; + ProviderManager.SaveMetadata(item, updateReason); } + + item.DateLastSaved = DateTime.UtcNow; + + return UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate); } /// <summary> diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 041619d1e..2070df31e 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -5,16 +5,17 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library @@ -23,14 +24,13 @@ namespace Emby.Server.Implementations.Library { private readonly IMediaEncoder _mediaEncoder; private readonly ILogger _logger; - private readonly IJsonSerializer _json; private readonly IApplicationPaths _appPaths; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); - public LiveStreamHelper(IMediaEncoder mediaEncoder, ILogger logger, IJsonSerializer json, IApplicationPaths appPaths) + public LiveStreamHelper(IMediaEncoder mediaEncoder, ILogger logger, IApplicationPaths appPaths) { _mediaEncoder = mediaEncoder; _logger = logger; - _json = json; _appPaths = appPaths; } @@ -47,7 +47,8 @@ namespace Emby.Server.Implementations.Library { try { - mediaInfo = _json.DeserializeFromFile<MediaInfo>(cacheFilePath); + await using FileStream jsonStream = File.OpenRead(cacheFilePath); + mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); } @@ -83,7 +84,8 @@ namespace Emby.Server.Implementations.Library if (cacheFilePath != null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - _json.SerializeToFile(mediaInfo, cacheFilePath); + await using FileStream createStream = File.OpenWrite(cacheFilePath); + await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 928f5f88e..660ec106b 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -6,12 +6,14 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -23,7 +25,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library @@ -36,7 +37,6 @@ namespace Emby.Server.Implementations.Library private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; - private readonly IJsonSerializer _jsonSerializer; private readonly IFileSystem _fileSystem; private readonly ILogger<MediaSourceManager> _logger; private readonly IUserDataManager _userDataManager; @@ -46,6 +46,7 @@ namespace Emby.Server.Implementations.Library private readonly ConcurrentDictionary<string, ILiveStream> _openStreams = new ConcurrentDictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); private IMediaSourceProvider[] _providers; @@ -56,7 +57,6 @@ namespace Emby.Server.Implementations.Library IUserManager userManager, ILibraryManager libraryManager, ILogger<MediaSourceManager> logger, - IJsonSerializer jsonSerializer, IFileSystem fileSystem, IUserDataManager userDataManager, IMediaEncoder mediaEncoder) @@ -65,7 +65,6 @@ namespace Emby.Server.Implementations.Library _userManager = userManager; _libraryManager = libraryManager; _logger = logger; - _jsonSerializer = jsonSerializer; _fileSystem = fileSystem; _userDataManager = userDataManager; _mediaEncoder = mediaEncoder; @@ -504,7 +503,7 @@ namespace Emby.Server.Implementations.Library // hack - these two values were taken from LiveTVMediaSourceProvider string cacheKey = request.OpenToken; - await new LiveStreamHelper(_mediaEncoder, _logger, _jsonSerializer, _appPaths) + await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths) .AddMediaInfoWithProbe(mediaSource, isAudio, cacheKey, true, cancellationToken) .ConfigureAwait(false); } @@ -516,9 +515,9 @@ namespace Emby.Server.Implementations.Library } // TODO: @bond Fix - var json = _jsonSerializer.SerializeToString(mediaSource); + var json = JsonSerializer.Serialize(mediaSource, _jsonOptions); _logger.LogInformation("Live stream opened: " + json); - var clone = _jsonSerializer.DeserializeFromString<MediaSourceInfo>(json); + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(json, _jsonOptions); if (!request.UserId.Equals(Guid.Empty)) { @@ -643,7 +642,8 @@ namespace Emby.Server.Implementations.Library { try { - mediaInfo = _jsonSerializer.DeserializeFromFile<MediaInfo>(cacheFilePath); + await using FileStream jsonStream = File.OpenRead(cacheFilePath); + mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); } @@ -679,7 +679,8 @@ namespace Emby.Server.Implementations.Library if (cacheFilePath != null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - _jsonSerializer.SerializeToFile(mediaInfo, cacheFilePath); + await using FileStream createStream = File.Create(cacheFilePath); + await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 59af7ce8a..86242d137 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -11,7 +11,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { public class BookResolver : MediaBrowser.Controller.Resolvers.ItemResolver<Book> { - private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".opf", ".pdf" }; + private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; protected override Book Resolve(ItemResolveArgs args) { diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index 1d9529dff..94602582b 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -47,7 +47,7 @@ namespace Emby.Server.Implementations.Library if (query.Limit.HasValue) { - results = results.GetRange(0, query.Limit.Value); + results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count)); } return new QueryResult<SearchHintInfo> diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index f9e5e6bbc..d16275b19 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -14,6 +14,7 @@ using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using Book = MediaBrowser.Controller.Entities.Book; +using AudioBook = MediaBrowser.Controller.Entities.AudioBook; namespace Emby.Server.Implementations.Library { @@ -219,7 +220,7 @@ namespace Emby.Server.Implementations.Library var hasRuntime = runtimeTicks > 0; // If a position has been reported, and if we know the duration - if (positionTicks > 0 && hasRuntime) + if (positionTicks > 0 && hasRuntime && !(item is AudioBook)) { var pctIn = decimal.Divide(positionTicks, runtimeTicks) * 100; @@ -245,6 +246,23 @@ namespace Emby.Server.Implementations.Library } } } + else if (positionTicks > 0 && hasRuntime && item is AudioBook) + { + var minIn = TimeSpan.FromTicks(positionTicks).TotalMinutes; + var minOut = TimeSpan.FromTicks(runtimeTicks - positionTicks).TotalMinutes; + + if (minIn > _config.Configuration.MinAudiobookResume) + { + // ignore progress during the beginning + positionTicks = 0; + } + else if (minOut < _config.Configuration.MaxAudiobookResume || positionTicks >= runtimeTicks) + { + // mark as completed close to the end + positionTicks = 0; + data.Played = playedToCompletion = true; + } + } else if (!hasRuntime) { // If we don't know the runtime we'll just have to assume it was fully played diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index f51657c63..b6b7ea949 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -129,23 +129,23 @@ namespace Emby.Server.Implementations.Library if (!query.IncludeHidden) { - list = list.Where(i => !user.GetPreference(PreferenceKind.MyMediaExcludes).Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))).ToList(); + list = list.Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes).Contains(i.Id)).ToList(); } var sorted = _libraryManager.Sort(list, user, new[] { ItemSortBy.SortName }, SortOrder.Ascending).ToList(); - var orders = user.GetPreference(PreferenceKind.OrderedViews).ToList(); + var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews); return list .OrderBy(i => { - var index = orders.IndexOf(i.Id.ToString("N", CultureInfo.InvariantCulture)); + var index = Array.IndexOf(orders, i.Id); if (index == -1 && i is UserView view && view.DisplayParentId != Guid.Empty) { - index = orders.IndexOf(view.DisplayParentId.ToString("N", CultureInfo.InvariantCulture)); + index = Array.IndexOf(orders, view.DisplayParentId); } return index == -1 ? int.MaxValue : index; @@ -280,8 +280,8 @@ namespace Emby.Server.Implementations.Library { parents = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes) - .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) + .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes) + .Contains(i.Id)) .ToList(); } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 0dc045ee6..2c0de661d 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -36,7 +36,6 @@ using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Providers; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -51,7 +50,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly ILogger<EmbyTV> _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; - private readonly IJsonSerializer _jsonSerializer; private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; private readonly TimerManager _timerProvider; @@ -81,7 +79,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV IStreamHelper streamHelper, IMediaSourceManager mediaSourceManager, ILogger<EmbyTV> logger, - IJsonSerializer jsonSerializer, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ILiveTvManager liveTvManager, @@ -103,12 +100,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _providerManager = providerManager; _mediaEncoder = mediaEncoder; _liveTvManager = (LiveTvManager)liveTvManager; - _jsonSerializer = jsonSerializer; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; - _seriesTimerProvider = new SeriesTimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "seriestimers.json")); - _timerProvider = new TimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "timers.json")); + _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); + _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); _timerProvider.TimerFired += OnTimerProviderTimerFired; _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; @@ -1052,7 +1048,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV IgnoreIndex = true }; - await new LiveStreamHelper(_mediaEncoder, _logger, _jsonSerializer, _config.CommonApplicationPaths) + await new LiveStreamHelper(_mediaEncoder, _logger, _config.CommonApplicationPaths) .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); return new List<MediaSourceInfo> @@ -1635,7 +1631,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) { - return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, _config); + return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); } return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index e6ee9819e..78a82118e 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -6,16 +6,17 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Json; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -25,10 +26,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly ILogger _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IServerApplicationPaths _appPaths; - private readonly IJsonSerializer _json; private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); private readonly IServerConfigurationManager _serverConfigurationManager; - + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); private bool _hasExited; private Stream _logFileStream; private string _targetPath; @@ -38,13 +38,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV ILogger logger, IMediaEncoder mediaEncoder, IServerApplicationPaths appPaths, - IJsonSerializer json, IServerConfigurationManager serverConfigurationManager) { _logger = logger; _mediaEncoder = mediaEncoder; _appPaths = appPaths; - _json = json; _serverConfigurationManager = serverConfigurationManager; } @@ -66,7 +64,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Recording completed to file {0}", targetFile); } - private Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { _targetPath = targetFile; Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); @@ -95,8 +93,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. _logFileStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); - var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(_json.SerializeToString(mediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); - _logFileStream.Write(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length); + await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); + await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); _process = new Process { @@ -115,8 +113,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); _logger.LogInformation("ffmpeg recording process started for {0}", _targetPath); - - return _taskCompletionSource.Task; } private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile, TimeSpan duration) diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs index fc543dc55..c80ecd6b3 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -4,7 +4,10 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using MediaBrowser.Model.Serialization; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using MediaBrowser.Common.Json; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -12,18 +15,16 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public class ItemDataProvider<T> where T : class { - private readonly IJsonSerializer _jsonSerializer; private readonly string _dataPath; private readonly object _fileDataLock = new object(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); private T[] _items; public ItemDataProvider( - IJsonSerializer jsonSerializer, ILogger logger, string dataPath, Func<T, T, bool> equalityComparer) { - _jsonSerializer = jsonSerializer; Logger = logger; _dataPath = dataPath; EqualityComparer = equalityComparer; @@ -46,7 +47,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { - _items = _jsonSerializer.DeserializeFromFile<T[]>(_dataPath); + var jsonString = File.ReadAllText(_dataPath, Encoding.UTF8); + _items = JsonSerializer.Deserialize<T[]>(jsonString, _jsonOptions); return; } catch (Exception ex) @@ -61,7 +63,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private void SaveList() { Directory.CreateDirectory(Path.GetDirectoryName(_dataPath)); - _jsonSerializer.SerializeToFile(_items, _dataPath); + var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); + File.WriteAllText(_dataPath, jsonString); } public IReadOnlyList<T> GetAll() diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs index 194e4606d..da707fec6 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs @@ -9,8 +9,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo> { - public SeriesTimerManager(IJsonSerializer jsonSerializer, ILogger logger, string dataPath) - : base(jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + public SeriesTimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) { } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs index dd479b7d1..1efa90e25 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -8,7 +8,6 @@ using System.Threading; using Jellyfin.Data.Events; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -17,8 +16,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase); - public TimerManager(IJsonSerializer jsonSerializer, ILogger logger, string dataPath) - : base(jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + public TimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) { } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 1084ddf74..7567ea312 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -9,16 +9,17 @@ using System.Net; using System.Net.Http; using System.Net.Mime; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.Listings @@ -28,7 +29,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; private readonly ILogger<SchedulesDirect> _logger; - private readonly IJsonSerializer _jsonSerializer; private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); private readonly IApplicationHost _appHost; @@ -36,16 +36,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); private DateTime _lastErrorResponse; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); public SchedulesDirect( ILogger<SchedulesDirect> logger, - IJsonSerializer jsonSerializer, IHttpClientFactory httpClientFactory, IApplicationHost appHost, ICryptoProvider cryptoProvider) { _logger = logger; - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _appHost = appHost; _cryptoProvider = cryptoProvider; @@ -104,7 +103,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings } }; - var requestString = _jsonSerializer.SerializeToString(requestList); + var requestString = JsonSerializer.Serialize(requestList, _jsonOptions); _logger.LogDebug("Request string for schedules is: {RequestString}", requestString); using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); @@ -112,7 +111,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings options.Headers.TryAddWithoutValidation("token", token); using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var dailySchedules = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Day>>(responseStream).ConfigureAwait(false); + var dailySchedules = await JsonSerializer.DeserializeAsync<List<ScheduleDirect.Day>>(responseStream, _jsonOptions).ConfigureAwait(false); _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); @@ -123,7 +122,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var programDetails = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ProgramDetails>>(innerResponseStream).ConfigureAwait(false); + var programDetails = await JsonSerializer.DeserializeAsync<List<ScheduleDirect.ProgramDetails>>(innerResponseStream, _jsonOptions).ConfigureAwait(false); var programDict = programDetails.ToDictionary(p => p.programID, y => y); var programIdsWithImages = @@ -479,7 +478,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings { using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); await using var response = await innerResponse2.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>(response).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync<List<ScheduleDirect.ShowImages>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -508,7 +507,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Headends>>(response).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<List<ScheduleDirect.Headends>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); if (root != null) { @@ -611,25 +610,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings CancellationToken cancellationToken, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { - try + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { - return await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + return response; } - catch (HttpRequestException ex) - { - _tokens.Clear(); - if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) - { - enableRetry = false; - } - - if (!enableRetry) - { - throw; - } + // Response is automatically disposed in the calling function, + // so dispose manually if not returning. + response.Dispose(); + if (!enableRetry || (int)response.StatusCode >= 500) + { + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); } + _tokens.Clear(); options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); } @@ -647,8 +646,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<ScheduleDirect.Token>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (string.Equals(root.message, "OK", StringComparison.Ordinal)) { _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token); @@ -701,15 +701,16 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + httpResponse.EnsureSuccessStatusCode(); await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var response = httpResponse.Content; - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(stream).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<ScheduleDirect.Lineups>(stream, _jsonOptions).ConfigureAwait(false); return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase)); } catch (HttpRequestException ex) { - // Apparently we're supposed to swallow this + // SchedulesDirect returns 400 if no lineups are configured. if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) { return false; @@ -775,25 +776,24 @@ namespace Emby.Server.Implementations.LiveTv.Listings using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(stream).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<ScheduleDirect.Channel>(stream, _jsonOptions).ConfigureAwait(false); _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count); _logger.LogInformation("Mapping Stations to Channel"); var allStations = root.stations ?? new List<ScheduleDirect.Station>(); var map = root.map; - int len = map.Count; - var array = new List<ChannelInfo>(len); - for (int i = 0; i < len; i++) + var list = new List<ChannelInfo>(map.Count); + foreach (var channel in map) { - var channelNumber = GetChannelNumber(map[i]); + var channelNumber = GetChannelNumber(channel); - var station = allStations.Find(item => string.Equals(item.stationID, map[i].stationID, StringComparison.OrdinalIgnoreCase)); + var station = allStations.Find(item => string.Equals(item.stationID, channel.stationID, StringComparison.OrdinalIgnoreCase)); if (station == null) { station = new ScheduleDirect.Station { - stationID = map[i].stationID + stationID = channel.stationID }; } @@ -810,10 +810,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings channelInfo.ImageUrl = station.logo.URL; } - array[i] = channelInfo; + list.Add(channelInfo); } - return array; + return list; } private static string NormalizeName(string value) diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index 8c9bb6ba0..7842be716 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1928,7 +1928,7 @@ namespace Emby.Server.Implementations.LiveTv foreach (var programDto in currentProgramDtos) { - if (currentChannelsDict.TryGetValue(programDto.ChannelId, out BaseItemDto channelDto)) + if (programDto.ChannelId.HasValue && currentChannelsDict.TryGetValue(programDto.ChannelId.Value, out BaseItemDto channelDto)) { channelDto.CurrentProgram = programDto; } @@ -2018,7 +2018,7 @@ namespace Emby.Server.Implementations.LiveTv info.DayPattern = _tvDtoService.GetDayPattern(info.Days); info.Name = program.Name; - info.ChannelId = programDto.ChannelId; + info.ChannelId = programDto.ChannelId ?? Guid.Empty; info.ChannelName = programDto.ChannelName; info.StartDate = program.StartDate; info.Name = program.Name; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs new file mode 100644 index 000000000..740cbb66e --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs @@ -0,0 +1,21 @@ +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + internal class Channels + { + public string GuideNumber { get; set; } + + public string GuideName { get; set; } + + public string VideoCodec { get; set; } + + public string AudioCodec { get; set; } + + public string URL { get; set; } + + public bool Favorite { get; set; } + + public bool DRM { get; set; } + + public bool HD { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs new file mode 100644 index 000000000..09d77f838 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs @@ -0,0 +1,40 @@ +using System; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + internal class DiscoverResponse + { + public string FriendlyName { get; set; } + + public string ModelNumber { get; set; } + + public string FirmwareName { get; set; } + + public string FirmwareVersion { get; set; } + + public string DeviceID { get; set; } + + public string DeviceAuth { get; set; } + + public string BaseURL { get; set; } + + public string LineupURL { get; set; } + + public int TunerCount { get; set; } + + public bool SupportsTranscoding + { + get + { + var model = ModelNumber ?? string.Empty; + + if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1) + { + return true; + } + + return false; + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index b6444b172..5ef83f274 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -8,10 +8,12 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -37,6 +39,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun private readonly INetworkManager _networkManager; private readonly IStreamHelper _streamHelper; + private readonly JsonSerializerOptions _jsonOptions; + private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>(); public HdHomerunHost( @@ -56,6 +60,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _socketFactory = socketFactory; _networkManager = networkManager; _streamHelper = streamHelper; + + _jsonOptions = JsonDefaults.GetOptions(); } public string Name => "HD Homerun"; @@ -67,13 +73,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun private string GetChannelId(TunerHostInfo info, Channels i) => ChannelIdPrefix + i.GuideNumber; - private async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken) + internal async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken) { var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, cancellationToken: cancellationToken) + var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, _jsonOptions, cancellationToken) .ConfigureAwait(false) ?? new List<Channels>(); if (info.ImportFavoritesOnly) @@ -100,7 +106,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Id = GetChannelId(info, i), IsFavorite = i.Favorite, TunerHostId = info.Id, - IsHD = i.HD == 1, + IsHD = i.HD, AudioCodec = i.AudioCodec, VideoCodec = i.VideoCodec, ChannelType = ChannelType.TV, @@ -109,7 +115,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun }).Cast<ChannelInfo>().ToList(); } - private async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken) + internal async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken) { var cacheKey = info.Id; @@ -127,10 +133,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun try { using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/discover.json", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, cancellationToken: cancellationToken) + var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, _jsonOptions, cancellationToken) .ConfigureAwait(false); if (!string.IsNullOrEmpty(cacheKey)) @@ -328,25 +335,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return new Uri(url).AbsoluteUri.TrimEnd('/'); } - private class Channels - { - public string GuideNumber { get; set; } - - public string GuideName { get; set; } - - public string VideoCodec { get; set; } - - public string AudioCodec { get; set; } - - public string URL { get; set; } - - public bool Favorite { get; set; } - - public bool DRM { get; set; } - - public int HD { get; set; } - } - protected EncodingOptions GetEncodingOptions() { return Config.GetConfiguration<EncodingOptions>("encoding"); @@ -674,42 +662,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - public class DiscoverResponse - { - public string FriendlyName { get; set; } - - public string ModelNumber { get; set; } - - public string FirmwareName { get; set; } - - public string FirmwareVersion { get; set; } - - public string DeviceID { get; set; } - - public string DeviceAuth { get; set; } - - public string BaseURL { get; set; } - - public string LineupURL { get; set; } - - public int TunerCount { get; set; } - - public bool SupportsTranscoding - { - get - { - var model = ModelNumber ?? string.Empty; - - if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1) - { - return true; - } - - return false; - } - } - } - public async Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken) { lock (_modelCache) @@ -762,7 +714,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return list; } - private async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken) + internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken) { var hostInfo = new TunerHostInfo { @@ -774,6 +726,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun hostInfo.DeviceId = modelInfo.DeviceID; hostInfo.FriendlyName = modelInfo.FriendlyName; + hostInfo.TunerCount = modelInfo.TunerCount; return hostInfo; } diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index 5667bf337..a23037af8 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -14,8 +14,8 @@ "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", "HeaderContinueWatching": "দেখতে থাকুন", - "HeaderAlbumArtists": "এলবাম শিল্পী", - "Genres": "জেনার", + "HeaderAlbumArtists": "এলবাম শিল্পীবৃন্দ", + "Genres": "শৈলী", "Folders": "ফোল্ডারগুলো", "Favorites": "পছন্দসমূহ", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", @@ -112,5 +112,10 @@ "TaskRefreshPeople": "পিপল রিফ্রেশ করুন", "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলুন।", "TaskCleanLogs": "লগ ডিরেক্টরি ক্লিন করুন", - "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।" + "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।", + "Undefined": "অসঙ্গায়িত", + "Forced": "জোরকরে", + "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের কাজের হিসাব মুছে দিন খালি করুন", + "TaskCleanActivityLog": "কাজের ফাইল খালি করুন", + "Default": "প্রাথমিক" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index cd64cdde4..7667612b9 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -115,5 +115,8 @@ "TasksLibraryCategory": "Library", "TasksMaintenanceCategory": "Maintenance", "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.", - "TaskCleanActivityLog": "Clean Activity Log" + "TaskCleanActivityLog": "Clean Activity Log", + "Undefined": "Undefined", + "Forced": "Forced", + "Default": "Default" } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index dcd30694f..03c6d5f5d 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -112,5 +112,9 @@ "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", "Application": "Aplicación", - "AppDeviceValues": "App: {0}, Dispositivo: {1}" + "AppDeviceValues": "App: {0}, Dispositivo: {1}", + "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", + "TaskCleanActivityLog": "Limpiar Registro de Actividades", + "Undefined": "Sin definir", + "Forced": "Forzado" } diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 1986decf0..7eb8e36e7 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -113,5 +113,7 @@ "TasksChannelsCategory": "کانالهای داخلی", "TasksApplicationCategory": "برنامه", "TasksLibraryCategory": "کتابخانه", - "TasksMaintenanceCategory": "تعمیر" + "TasksMaintenanceCategory": "تعمیر", + "Forced": "اجباری", + "Default": "پیشفرض" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 5aa65a525..3c51d64e0 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -1,9 +1,9 @@ { "Albums": "Albums", - "AppDeviceValues": "Application : {0}, Appareil : {1}", + "AppDeviceValues": "App : {0}, Appareil : {1}", "Application": "Application", "Artists": "Artistes", - "AuthenticationSucceededWithUserName": "{0} s'est authentifié avec succès", + "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", "CameraImageUploadedFrom": "Une nouvelle image de caméra a été téléchargée depuis {0}", "Channels": "Chaînes", @@ -11,12 +11,12 @@ "Collections": "Collections", "DeviceOfflineWithName": "{0} s'est déconnecté", "DeviceOnlineWithName": "{0} est connecté", - "FailedLoginAttemptWithUserName": "Échec d'une tentative de connexion de {0}", + "FailedLoginAttemptWithUserName": "Tentative de connexion échoué par {0}", "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", "HeaderAlbumArtists": "Artistes de l'album", - "HeaderContinueWatching": "Continuer à regarder", + "HeaderContinueWatching": "Reprendre le visionnement", "HeaderFavoriteAlbums": "Albums favoris", "HeaderFavoriteArtists": "Artistes favoris", "HeaderFavoriteEpisodes": "Épisodes favoris", @@ -26,12 +26,12 @@ "HeaderNextUp": "À Suivre", "HeaderRecordingGroups": "Groupes d'enregistrements", "HomeVideos": "Vidéos personnelles", - "Inherit": "Hériter", + "Inherit": "Hérite", "ItemAddedWithName": "{0} a été ajouté à la médiathèque", "ItemRemovedWithName": "{0} a été supprimé de la médiathèque", "LabelIpAddressValue": "Adresse IP : {0}", "LabelRunningTimeValue": "Durée : {0}", - "Latest": "Derniers", + "Latest": "Plus récent", "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour", "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour vers la version {0}", "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour", @@ -40,15 +40,15 @@ "Movies": "Films", "Music": "Musique", "MusicVideos": "Vidéos musicales", - "NameInstallFailed": "{0} échec d'installation", + "NameInstallFailed": "échec d'installation de {0}", "NameSeasonNumber": "Saison {0}", "NameSeasonUnknown": "Saison Inconnue", - "NewVersionIsAvailable": "Une nouvelle version du serveur Jellyfin est disponible au téléchargement.", + "NewVersionIsAvailable": "Une nouvelle version du serveur Jellyfin est disponible.", "NotificationOptionApplicationUpdateAvailable": "Mise à jour de l'application disponible", "NotificationOptionApplicationUpdateInstalled": "Mise à jour de l'application installée", "NotificationOptionAudioPlayback": "Lecture audio démarrée", "NotificationOptionAudioPlaybackStopped": "Lecture audio arrêtée", - "NotificationOptionCameraImageUploaded": "L'image de l'appareil photo a été transférée", + "NotificationOptionCameraImageUploaded": "Image d'appareil photo transférée", "NotificationOptionInstallationFailed": "Échec d'installation", "NotificationOptionNewLibraryContent": "Nouveau contenu ajouté", "NotificationOptionPluginError": "Erreur d'extension", @@ -70,9 +70,9 @@ "ScheduledTaskFailedWithName": "{0} a échoué", "ScheduledTaskStartedWithName": "{0} a commencé", "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", - "Shows": "Émissions", + "Shows": "Séries", "Songs": "Chansons", - "StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.", + "StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", "Sync": "Synchroniser", @@ -80,39 +80,43 @@ "TvShows": "Séries Télé", "User": "Utilisateur", "UserCreatedWithName": "L'utilisateur {0} a été créé", - "UserDeletedWithName": "L'utilisateur {0} a été supprimé", - "UserDownloadingItemWithValues": "{0} est en train de télécharger {1}", + "UserDeletedWithName": "L'utilisateur {0} supprimé", + "UserDownloadingItemWithValues": "{0} télécharge {1}", "UserLockedOutWithName": "L'utilisateur {0} a été verrouillé", - "UserOfflineFromDevice": "{0} s'est déconnecté depuis {1}", - "UserOnlineFromDevice": "{0} s'est connecté depuis {1}", - "UserPasswordChangedWithName": "Le mot de passe pour l'utilisateur {0} a été modifié", + "UserOfflineFromDevice": "{0} s'est déconnecté de {1}", + "UserOnlineFromDevice": "{0} s'est connecté de {1}", + "UserPasswordChangedWithName": "Le mot de passe de utilisateur {0} a été modifié", "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}", - "UserStartedPlayingItemWithValues": "{0} est en train de lire {1} sur {2}", - "UserStoppedPlayingItemWithValues": "{0} vient d'arrêter la lecture de {1} sur {2}", + "UserStartedPlayingItemWithValues": "{0} joue {1} sur {2}", + "UserStoppedPlayingItemWithValues": "{0} a terminé la lecture de {1} sur {2}", "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque", "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", - "TasksLibraryCategory": "Bibliothèque", + "TasksLibraryCategory": "Médiathèque", "TasksMaintenanceCategory": "Entretien", - "TaskDownloadMissingSubtitlesDescription": "Recherche l'internet pour des sous-titres manquants à base de métadonnées configurées.", + "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquant sur l'internet selon la configuration des métadonnées.", "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants", - "TaskRefreshChannelsDescription": "Rafraîchit des informations des chaines internet.", - "TaskRefreshChannels": "Rafraîchir des chaines", - "TaskCleanTranscodeDescription": "Supprime les fichiers de transcodage de plus d'un jour.", + "TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines internet.", + "TaskRefreshChannels": "Rafraîchir les chaines", + "TaskCleanTranscodeDescription": "Supprime les fichiers de transcodage datant de plus d'un jour.", "TaskCleanTranscode": "Nettoyer le répertoire de transcodage", - "TaskUpdatePluginsDescription": "Télécharger et installer les mises à jours des extensions qui sont configurés pour les m.à.j. automisés.", + "TaskUpdatePluginsDescription": "Télécharge et installe les mises à jours des extensions configurés pour les m.à.j. automatiques.", "TaskUpdatePlugins": "Mise à jour des extensions", - "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque de médias.", - "TaskRefreshPeople": "Rafraîchir les acteurs", - "TaskCleanLogsDescription": "Supprime les journaux qui ont plus que {0} jours.", + "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre médiathèque.", + "TaskRefreshPeople": "Rafraîchir les personnes", + "TaskCleanLogsDescription": "Supprime les journaux plus vieux que {0} jours.", "TaskCleanLogs": "Nettoyer le répertoire des journaux", - "TaskRefreshLibraryDescription": "Analyse votre bibliothèque média pour trouver de nouveaux fichiers et rafraîchit les métadonnées.", + "TaskRefreshLibraryDescription": "Analyse votre médiathèque pour trouver de nouveaux fichiers et rafraîchit les métadonnées.", "TaskRefreshChapterImages": "Extraire les images de chapitre", "TaskRefreshChapterImagesDescription": "Créer des vignettes pour les vidéos qui ont des chapitres.", - "TaskRefreshLibrary": "Analyser la bibliothèque de médias", + "TaskRefreshLibrary": "Analyser la médiathèque", "TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires", "TasksApplicationCategory": "Application", "TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système.", - "TasksChannelsCategory": "Canaux Internet", - "Default": "Par défaut" + "TasksChannelsCategory": "Chaines Internet", + "Default": "Par défaut", + "TaskCleanActivityLogDescription": "Éfface les entrées du journal plus anciennes que l'âge configuré.", + "TaskCleanActivityLog": "Nettoyer le journal d'activité", + "Undefined": "Indéfini", + "Forced": "Forcé" } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index df68d3bbd..4cc2b378b 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -1,3 +1,30 @@ { - "Albums": "आल्बुम्" + "Albums": "संग्रह", + "HeaderRecordingGroups": "रिकॉर्डिंग समूह", + "HeaderNextUp": "इसके बाद", + "HeaderLiveTV": "लाइव टीवी", + "HeaderFavoriteSongs": "पसंदीदा गीत", + "HeaderFavoriteShows": "पसंदीदा शोज", + "HeaderFavoriteEpisodes": "पसंदीदा एपिसोड्स", + "HeaderFavoriteArtists": "पसंदीदा कलाकारसमूह", + "HeaderFavoriteAlbums": "पसंदीदा एलबम्स", + "HeaderContinueWatching": "देखते रहिए", + "HeaderAlbumArtists": "एल्बम कलकरसमुह", + "Genres": "शैली", + "Forced": "बलपूर्वक", + "Folders": "फोल्डेरें", + "Favorites": "पसंदीदा", + "FailedLoginAttemptWithUserName": "{0} से लॉगिन असफल हुआ है", + "DeviceOnlineWithName": "{0} से संयोग हो गया है", + "DeviceOfflineWithName": "{0} से संयोग विच्छिन्न हो गया है", + "Default": "प्राथमिक", + "Collections": "संग्रह", + "ChapterNameValue": "अध्याय", + "Channels": "चैनल", + "CameraImageUploadedFrom": "कैमरा से एक नया चित्र अपलोड किया गया है", + "Books": "किताब", + "AuthenticationSucceededWithUserName": "सफलता से प्रमाणीकृत", + "Artists": "कलाकारों", + "Application": "एप्लिकेशन", + "AppDeviceValues": "एप: {0}, मशीन: {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 9be91b724..9eb80b83b 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -115,5 +115,8 @@ "TasksChannelsCategory": "Internet kanali", "TasksLibraryCategory": "Biblioteka", "TaskCleanActivityLogDescription": "Briše zapise dnevnika aktivnosti starije od navedenog vremena.", - "TaskCleanActivityLog": "Očisti dnevnik aktivnosti" + "TaskCleanActivityLog": "Očisti dnevnik aktivnosti", + "Undefined": "Nedefinirano", + "Forced": "Forsirani", + "Default": "Zadano" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 02bf8496f..fa0ab8b92 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -4,7 +4,7 @@ "Application": "アプリケーション", "Artists": "アーティスト", "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", - "Books": "ブック", + "Books": "ブックス", "CameraImageUploadedFrom": "新しいカメライメージが {0}からアップロードされました", "Channels": "チャンネル", "ChapterNameValue": "チャプター {0}", @@ -114,5 +114,8 @@ "TaskRefreshChapterImages": "チャプター画像を抽出する", "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする", "TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。", - "TaskCleanActivityLog": "アクティビティの履歴を消去" + "TaskCleanActivityLog": "アクティビティの履歴を消去", + "Undefined": "未定義", + "Forced": "強制", + "Default": "デフォルト" } diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index 91c1fb15b..7ce9822b6 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -91,5 +91,32 @@ "UserStoppedPlayingItemWithValues": "{0} - {1} oınatýyn {2} toqtatty", "ValueHasBeenAddedToLibrary": "{0} (tasyǵyshhanaǵa ústelindi)", "ValueSpecialEpisodeName": "Arnaıy - {0}", - "VersionNumber": "Nusqasy {0}" + "VersionNumber": "Nusqasy {0}", + "Default": "Ádepki", + "TaskDownloadMissingSubtitles": "Joq sýbtıtrlerdi júktep alý", + "TaskRefreshChannels": "Arnalardy jańartý", + "TaskCleanTranscode": "Qaıta kodtaý katalogyn tazalaý", + "TaskUpdatePlugins": "Plagınderdi jańartý", + "TaskRefreshPeople": "Adamdardy jańartý", + "TaskCleanLogs": "Jurnal katalogyn tazalaý", + "TaskRefreshLibrary": "Tasyǵyshhanany skanerleý", + "TaskRefreshChapterImages": "Sahna keskinderin shyǵaryp alý", + "TaskCleanCache": "Kesh katalogyn tazalaý", + "TaskCleanActivityLog": "Áreket jurnalyn tazalaý", + "TasksChannelsCategory": "Internet-arnalar", + "TasksApplicationCategory": "Qoldanba", + "TasksLibraryCategory": "Tasyǵyshhana", + "TasksMaintenanceCategory": "Qyzmet kórsetý", + "Undefined": "Anyqtalmady", + "Forced": "Májbúrli", + "TaskDownloadMissingSubtitlesDescription": "Metaderekter teńshelimi negіzіnde joq sýbtıtrlerdі Internetten іzdeıdі.", + "TaskRefreshChannelsDescription": "Internet-arnalar málimetterin jańartady.", + "TaskCleanTranscodeDescription": "Bіr kúnnen asqan qaıta kodtaý faıldaryn joıady.", + "TaskUpdatePluginsDescription": "Avtomatty túrde jańartýǵa teńshelgen plagınder úshin jańartýlardy júktep alady jáne ornatady.", + "TaskRefreshPeopleDescription": "Tasyǵyshhanadaǵy aktórler men rejısórler metaderekterіn jańartady.", + "TaskCleanLogsDescription": "{0} kúnnen asqan jurnal faıldaryn joıady.", + "TaskRefreshLibraryDescription": "Tasyǵyshhanadaǵy jańa faıldardy skanerleıdі jáne metaderekterdі jańartady.", + "TaskRefreshChapterImagesDescription": "Sahnalarǵa bólіngen beıneler úshіn nobaılar jasaıdy.", + "TaskCleanCacheDescription": "Júıede qajet emes keshtelgen faıldardy joıady.", + "TaskCleanActivityLogDescription": "Áreketter jurnalyndaǵy teńshelgen jasynan asqan jazbalaly joıady." } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 5e3acfbe9..a46bdc3de 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -15,9 +15,9 @@ "NotificationOptionUserLockedOut": "Lietotājs bloķēts", "LabelRunningTimeValue": "Garums: {0}", "Inherit": "Mantot", - "AppDeviceValues": "Lietotne:{0}, Ierīce:{1}", + "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", "VersionNumber": "Versija {0}", - "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots tavai multvides bibliotēkai", + "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai", "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}", "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}", "UserPasswordChangedWithName": "Parole nomainīta lietotājam {0}", @@ -95,7 +95,7 @@ "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus", "TasksApplicationCategory": "Lietotne", "TasksLibraryCategory": "Bibliotēka", - "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus pēc metadatu uzstādījumiem.", + "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.", "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus", "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.", "TaskRefreshChannels": "Atjaunot Kanālus", @@ -103,14 +103,19 @@ "TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi", "TaskUpdatePluginsDescription": "Lejupielādē un uzstāda atjauninājumus paplašinājumiem, kam ir uzstādīta automātiskā atjaunināšana.", "TaskUpdatePlugins": "Atjaunot Paplašinājumus", - "TaskRefreshPeopleDescription": "Atjauno metadatus priekš aktieriem un direktoriem tavā mediju bibliotēkā.", + "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.", "TaskRefreshPeople": "Atjaunot Cilvēkus", "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.", "TaskCleanLogs": "Iztīrīt Logdatņu Mapi", - "TaskRefreshLibraryDescription": "Skenē tavas mediju bibliotēkas priekš jaunām datnēm un atjauno metadatus.", - "TaskRefreshLibrary": "Skanēt Mediju Bibliotēku", + "TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.", + "TaskRefreshLibrary": "Skenēt Multivides Bibliotēku", "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.", "TaskCleanCache": "Iztīrīt Kešošanas Mapi", "TasksChannelsCategory": "Interneta Kanāli", - "TasksMaintenanceCategory": "Apkope" + "TasksMaintenanceCategory": "Apkope", + "Forced": "Piespiests", + "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.", + "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu", + "Undefined": "Nenoteikts", + "Default": "Noklusējums" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index b6672a554..1e80d0b5f 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -1,7 +1,7 @@ { "Albums": "Albums", "AppDeviceValues": "App: {0}, Apparaat: {1}", - "Application": "Programma", + "Application": "Applicatie", "Artists": "Artiesten", "AuthenticationSucceededWithUserName": "{0} is succesvol geverifieerd", "Books": "Boeken", diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index ca6172fce..03d30247a 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -96,7 +96,7 @@ "TaskRefreshChannels": "Обновление каналов", "TaskCleanTranscode": "Очистка каталога перекодировки", "TaskUpdatePlugins": "Обновление плагинов", - "TaskRefreshPeople": "Обновление метаданных людей", + "TaskRefreshPeople": "Подновить людей", "TaskCleanLogs": "Очистка каталога журналов", "TaskRefreshLibrary": "Сканирование медиатеки", "TaskRefreshChapterImages": "Извлечение изображений сцен", @@ -109,7 +109,7 @@ "TaskRefreshChannelsDescription": "Обновляются данные интернет-каналов.", "TaskCleanTranscodeDescription": "Удаляются файлы перекодировки старше одного дня.", "TaskUpdatePluginsDescription": "Загружаются и устанавливаются обновления для плагинов, у которых включено автоматическое обновление.", - "TaskRefreshPeopleDescription": "Обновляются метаданные актеров и режиссёров в медиатеке.", + "TaskRefreshPeopleDescription": "Обновляются метаданные для актёров и режиссёров в медиатеке.", "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).", "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.", "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.", diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 66681f025..343e067b7 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -113,5 +113,10 @@ "TasksApplicationCategory": "Aplikacija", "TasksLibraryCategory": "Knjižnica", "TasksMaintenanceCategory": "Vzdrževanje", - "TaskDownloadMissingSubtitlesDescription": "Na podlagi nastavitev metapodatkov poišče manjkajoče podnapise na internetu." + "TaskDownloadMissingSubtitlesDescription": "Na podlagi nastavitev metapodatkov poišče manjkajoče podnapise na internetu.", + "TaskCleanActivityLogDescription": "Počisti zapise v dnevniku aktivnosti starejše od nastavljenega časa.", + "TaskCleanActivityLog": "Počisti dnevnik aktivnosti", + "Undefined": "Nedoločen", + "Forced": "Prisilno", + "Default": "Privzeto" } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 8da92f309..d785bcb90 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -66,7 +66,7 @@ "Inherit": "Наследи", "HomeVideos": "Кућни видео", "HeaderRecordingGroups": "Групе снимања", - "HeaderNextUp": "Следеће горе", + "HeaderNextUp": "Следи", "HeaderLiveTV": "ТВ уживо", "HeaderFavoriteSongs": "Омиљене песме", "HeaderFavoriteShows": "Омиљене серије", @@ -79,17 +79,17 @@ "Folders": "Фасцикле", "Favorites": "Омиљено", "FailedLoginAttemptWithUserName": "Неуспела пријава са {0}", - "DeviceOnlineWithName": "{0} се повезао", + "DeviceOnlineWithName": "{0} је повезан", "DeviceOfflineWithName": "{0} је прекинуо везу", "Collections": "Колекције", "ChapterNameValue": "Поглавље {0}", "Channels": "Канали", - "CameraImageUploadedFrom": "Нова фотографија је послата са {0}", + "CameraImageUploadedFrom": "Нова фотографија је учитана са {0}", "Books": "Књиге", "AuthenticationSucceededWithUserName": "{0} успешно проверено", - "Artists": "Извођач", + "Artists": "Извођачи", "Application": "Апликација", - "AppDeviceValues": "Апл: {0}, уређај: {1}", + "AppDeviceValues": "Апликација: {0}, Уређај: {1}", "Albums": "Албуми", "TaskDownloadMissingSubtitlesDescription": "Претражује интернет за недостајуће титлове на основу конфигурације метаподатака.", "TaskDownloadMissingSubtitles": "Преузмите недостајуће титлове", @@ -104,7 +104,7 @@ "TaskCleanLogsDescription": "Брише логове старије од {0} дана.", "TaskCleanLogs": "Очистите директоријум логова", "TaskRefreshLibraryDescription": "Скенира вашу медијску библиотеку за нове датотеке и освежава метаподатке.", - "TaskRefreshLibrary": "Скенирај Библиотеку Медија", + "TaskRefreshLibrary": "Скенирај библиотеку медија", "TaskRefreshChapterImagesDescription": "Ствара сличице за видео записе који имају поглавља.", "TaskRefreshChapterImages": "Издвоји слике из поглавља", "TaskCleanCacheDescription": "Брише Кеш фајлове који више нису потребни систему.", @@ -114,5 +114,8 @@ "TasksLibraryCategory": "Библиотека", "TasksMaintenanceCategory": "Одржавање", "TaskCleanActivityLogDescription": "Брише историју активности старију од конфигурисаног броја година.", - "TaskCleanActivityLog": "Очисти историју активности" + "TaskCleanActivityLog": "Очисти историју активности", + "Undefined": "Недефинисано", + "Forced": "Форсирано", + "Default": "Подразумевано" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 30aaf3a05..3f9e22106 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -5,7 +5,9 @@ using System.Globalization; using System.IO; using System.Linq; using System.Reflection; +using System.Text.Json; using System.Threading.Tasks; +using MediaBrowser.Common.Json; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; @@ -24,7 +26,6 @@ namespace Emby.Server.Implementations.Localization private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated" }; private readonly IServerConfigurationManager _configurationManager; - private readonly IJsonSerializer _jsonSerializer; private readonly ILogger<LocalizationManager> _logger; private readonly Dictionary<string, Dictionary<string, ParentalRating>> _allParentalRatings = @@ -35,19 +36,18 @@ namespace Emby.Server.Implementations.Localization private List<CultureDto> _cultures; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); + /// <summary> /// Initializes a new instance of the <see cref="LocalizationManager" /> class. /// </summary> /// <param name="configurationManager">The configuration manager.</param> - /// <param name="jsonSerializer">The json serializer.</param> /// <param name="logger">The logger.</param> public LocalizationManager( IServerConfigurationManager configurationManager, - IJsonSerializer jsonSerializer, ILogger<LocalizationManager> logger) { _configurationManager = configurationManager; - _jsonSerializer = jsonSerializer; _logger = logger; } @@ -179,8 +179,11 @@ namespace Emby.Server.Implementations.Localization /// <inheritdoc /> public IEnumerable<CountryInfo> GetCountries() - => _jsonSerializer.DeserializeFromStream<IEnumerable<CountryInfo>>( - _assembly.GetManifestResourceStream("Emby.Server.Implementations.Localization.countries.json")); + { + StreamReader reader = new StreamReader(_assembly.GetManifestResourceStream("Emby.Server.Implementations.Localization.countries.json")); + + return JsonSerializer.Deserialize<IEnumerable<CountryInfo>>(reader.ReadToEnd(), _jsonOptions); + } /// <inheritdoc /> public IEnumerable<ParentalRating> GetParentalRatings() @@ -344,7 +347,7 @@ namespace Emby.Server.Implementations.Localization // If a Culture doesn't have a translation the stream will be null and it defaults to en-us further up the chain if (stream != null) { - var dict = await _jsonSerializer.DeserializeFromStreamAsync<Dictionary<string, string>>(stream).ConfigureAwait(false); + var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).ConfigureAwait(false); foreach (var key in dict.Keys) { diff --git a/Emby.Server.Implementations/Properties/AssemblyInfo.cs b/Emby.Server.Implementations/Properties/AssemblyInfo.cs index a1933f66e..cb7972173 100644 --- a/Emby.Server.Implementations/Properties/AssemblyInfo.cs +++ b/Emby.Server.Implementations/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index 3a9e28458..29440b64a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -4,13 +4,15 @@ using System; using System.Globalization; using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Progress; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -21,11 +23,6 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> public class ScheduledTaskWorker : IScheduledTaskWorker { - /// <summary> - /// Gets or sets the json serializer. - /// </summary> - /// <value>The json serializer.</value> - private readonly IJsonSerializer _jsonSerializer; /// <summary> /// Gets or sets the application paths. @@ -70,12 +67,16 @@ namespace Emby.Server.Implementations.ScheduledTasks private string _id; /// <summary> + /// The options for the json Serializer. + /// </summary> + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); + + /// <summary> /// Initializes a new instance of the <see cref="ScheduledTaskWorker" /> class. /// </summary> /// <param name="scheduledTask">The scheduled task.</param> /// <param name="applicationPaths">The application paths.</param> /// <param name="taskManager">The task manager.</param> - /// <param name="jsonSerializer">The json serializer.</param> /// <param name="logger">The logger.</param> /// <exception cref="ArgumentNullException"> /// scheduledTask @@ -88,7 +89,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// or /// logger. /// </exception> - public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, IJsonSerializer jsonSerializer, ILogger logger) + public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger) { if (scheduledTask == null) { @@ -105,11 +106,6 @@ namespace Emby.Server.Implementations.ScheduledTasks throw new ArgumentNullException(nameof(taskManager)); } - if (jsonSerializer == null) - { - throw new ArgumentNullException(nameof(jsonSerializer)); - } - if (logger == null) { throw new ArgumentNullException(nameof(logger)); @@ -118,7 +114,6 @@ namespace Emby.Server.Implementations.ScheduledTasks ScheduledTask = scheduledTask; _applicationPaths = applicationPaths; _taskManager = taskManager; - _jsonSerializer = jsonSerializer; _logger = logger; InitTriggerEvents(); @@ -150,7 +145,15 @@ namespace Emby.Server.Implementations.ScheduledTasks { try { - _lastExecutionResult = _jsonSerializer.DeserializeFromFile<TaskResult>(path); + var jsonString = File.ReadAllText(path, Encoding.UTF8); + if (!string.IsNullOrWhiteSpace(jsonString)) + { + _lastExecutionResult = JsonSerializer.Deserialize<TaskResult>(jsonString, _jsonOptions); + } + else + { + _logger.LogDebug("Scheduled Task history file {Path} is empty. Skipping deserialization.", path); + } } catch (Exception ex) { @@ -174,7 +177,8 @@ namespace Emby.Server.Implementations.ScheduledTasks lock (_lastExecutionResultSyncLock) { - _jsonSerializer.SerializeToFile(value, path); + using FileStream createStream = File.OpenWrite(path); + JsonSerializer.SerializeAsync(createStream, value, _jsonOptions); } } } @@ -537,7 +541,8 @@ namespace Emby.Server.Implementations.ScheduledTasks TaskTriggerInfo[] list = null; if (File.Exists(path)) { - list = _jsonSerializer.DeserializeFromFile<TaskTriggerInfo[]>(path); + var jsonString = File.ReadAllText(path, Encoding.UTF8); + list = JsonSerializer.Deserialize<TaskTriggerInfo[]>(jsonString, _jsonOptions); } // Return defaults if file doesn't exist. @@ -573,7 +578,8 @@ namespace Emby.Server.Implementations.ScheduledTasks Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(triggers, path); + var json = JsonSerializer.Serialize(triggers, _jsonOptions); + File.WriteAllText(path, json, Encoding.UTF8); } /// <summary> diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index cfbf03ddc..af316e108 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading.Tasks; using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -19,6 +18,7 @@ namespace Emby.Server.Implementations.ScheduledTasks public class TaskManager : ITaskManager { public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + public event EventHandler<TaskCompletionEventArgs> TaskCompleted; /// <summary> @@ -33,7 +33,6 @@ namespace Emby.Server.Implementations.ScheduledTasks private readonly ConcurrentQueue<Tuple<Type, TaskOptions>> _taskQueue = new ConcurrentQueue<Tuple<Type, TaskOptions>>(); - private readonly IJsonSerializer _jsonSerializer; private readonly IApplicationPaths _applicationPaths; private readonly ILogger<TaskManager> _logger; @@ -41,15 +40,12 @@ namespace Emby.Server.Implementations.ScheduledTasks /// Initializes a new instance of the <see cref="TaskManager" /> class. /// </summary> /// <param name="applicationPaths">The application paths.</param> - /// <param name="jsonSerializer">The json serializer.</param> /// <param name="logger">The logger.</param> public TaskManager( IApplicationPaths applicationPaths, - IJsonSerializer jsonSerializer, ILogger<TaskManager> logger) { _applicationPaths = applicationPaths; - _jsonSerializer = jsonSerializer; _logger = logger; ScheduledTasks = Array.Empty<IScheduledTaskWorker>(); @@ -196,7 +192,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="tasks">The tasks.</param> public void AddTasks(IEnumerable<IScheduledTask> tasks) { - var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _jsonSerializer, _logger)); + var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger)); ScheduledTasks = ScheduledTasks.Concat(list).ToArray(); } diff --git a/Emby.Server.Implementations/Serialization/JsonSerializer.cs b/Emby.Server.Implementations/Serialization/JsonSerializer.cs deleted file mode 100644 index 5ec3a735a..000000000 --- a/Emby.Server.Implementations/Serialization/JsonSerializer.cs +++ /dev/null @@ -1,281 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.IO; -using System.Threading.Tasks; -using MediaBrowser.Model.Serialization; - -namespace Emby.Server.Implementations.Serialization -{ - /// <summary> - /// Provides a wrapper around third party json serialization. - /// </summary> - public class JsonSerializer : IJsonSerializer - { - /// <summary> - /// Initializes a new instance of the <see cref="JsonSerializer" /> class. - /// </summary> - public JsonSerializer() - { - ServiceStack.Text.JsConfig.DateHandler = ServiceStack.Text.DateHandler.ISO8601; - ServiceStack.Text.JsConfig.ExcludeTypeInfo = true; - ServiceStack.Text.JsConfig.IncludeNullValues = false; - ServiceStack.Text.JsConfig.AlwaysUseUtc = true; - ServiceStack.Text.JsConfig.AssumeUtc = true; - - ServiceStack.Text.JsConfig<Guid>.SerializeFn = SerializeGuid; - } - - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - public void SerializeToStream(object obj, Stream stream) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - ServiceStack.Text.JsonSerializer.SerializeToStream(obj, obj.GetType(), stream); - } - - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - public void SerializeToStream<T>(T obj, Stream stream) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - ServiceStack.Text.JsonSerializer.SerializeToStream<T>(obj, stream); - } - - /// <summary> - /// Serializes to file. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="file">The file.</param> - /// <exception cref="ArgumentNullException">obj</exception> - public void SerializeToFile(object obj, string file) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - if (string.IsNullOrEmpty(file)) - { - throw new ArgumentNullException(nameof(file)); - } - - using (var stream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Read)) - { - SerializeToStream(obj, stream); - } - } - - private static Stream OpenFile(string path) - { - return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 131072); - } - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <param name="type">The type.</param> - /// <param name="file">The file.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">type</exception> - public object DeserializeFromFile(Type type, string file) - { - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - if (string.IsNullOrEmpty(file)) - { - throw new ArgumentNullException(nameof(file)); - } - - using (var stream = OpenFile(file)) - { - return DeserializeFromStream(stream, type); - } - } - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="file">The file.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">file</exception> - public T DeserializeFromFile<T>(string file) - where T : class - { - if (string.IsNullOrEmpty(file)) - { - throw new ArgumentNullException(nameof(file)); - } - - using (var stream = OpenFile(file)) - { - return DeserializeFromStream<T>(stream); - } - } - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="stream">The stream.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - public T DeserializeFromStream<T>(Stream stream) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromStream<T>(stream); - } - - public Task<T> DeserializeFromStreamAsync<T>(Stream stream) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromStreamAsync<T>(stream); - } - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="text">The text.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">text</exception> - public T DeserializeFromString<T>(string text) - { - if (string.IsNullOrEmpty(text)) - { - throw new ArgumentNullException(nameof(text)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromString<T>(text); - } - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <param name="stream">The stream.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - public object DeserializeFromStream(Stream stream, Type type) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromStream(type, stream); - } - - public async Task<object> DeserializeFromStreamAsync(Stream stream, Type type) - { - if (stream == null) - { - throw new ArgumentNullException(nameof(stream)); - } - - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - using (var reader = new StreamReader(stream)) - { - var json = await reader.ReadToEndAsync().ConfigureAwait(false); - - return ServiceStack.Text.JsonSerializer.DeserializeFromString(json, type); - } - } - - private static string SerializeGuid(Guid guid) - { - if (guid.Equals(Guid.Empty)) - { - return null; - } - - return guid.ToString("N", CultureInfo.InvariantCulture); - } - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <param name="json">The json.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">json</exception> - public object DeserializeFromString(string json, Type type) - { - if (string.IsNullOrEmpty(json)) - { - throw new ArgumentNullException(nameof(json)); - } - - if (type == null) - { - throw new ArgumentNullException(nameof(type)); - } - - return ServiceStack.Text.JsonSerializer.DeserializeFromString(json, type); - } - - /// <summary> - /// Serializes to string. - /// </summary> - /// <param name="obj">The obj.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">obj</exception> - public string SerializeToString(object obj) - { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } - - return ServiceStack.Text.JsonSerializer.SerializeToString(obj, obj.GetType()); - } - } -} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index b3965fcca..885f65c64 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -128,6 +128,9 @@ namespace Emby.Server.Implementations.Session /// <inheritdoc /> public event EventHandler<SessionEventArgs> SessionActivity; + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> SessionControllerConnected; + /// <summary> /// Gets all connections. /// </summary> @@ -313,6 +316,19 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> + public void OnSessionControllerConnected(SessionInfo info) + { + EventHelper.QueueEventIfNotNull( + SessionControllerConnected, + this, + new SessionEventArgs + { + SessionInfo = info + }, + _logger); + } + + /// <inheritdoc /> public void CloseIfNeeded(SessionInfo session) { if (!session.SessionControllers.Any(i => i.IsSessionActive)) diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 169eaefd8..39c369a01 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -133,6 +133,8 @@ namespace Emby.Server.Implementations.Session var controller = (WebSocketController)controllerInfo.Item1; controller.AddWebSocket(connection); + + _sessionManager.OnSessionControllerConnected(session); } /// <summary> diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 348213ee1..aee959c53 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -42,6 +42,12 @@ namespace Emby.Server.Implementations.SyncPlay private readonly ILibraryManager _libraryManager; /// <summary> + /// The map between users and counter of active sessions. + /// </summary> + private readonly ConcurrentDictionary<Guid, int> _activeUsers = + new ConcurrentDictionary<Guid, int>(); + + /// <summary> /// The map between sessions and groups. /// </summary> private readonly ConcurrentDictionary<string, Group> _sessionToGroupMap = @@ -81,7 +87,7 @@ namespace Emby.Server.Implementations.SyncPlay _sessionManager = sessionManager; _libraryManager = libraryManager; _logger = loggerFactory.CreateLogger<SyncPlayManager>(); - _sessionManager.SessionStarted += OnSessionManagerSessionStarted; + _sessionManager.SessionControllerConnected += OnSessionControllerConnected; } /// <inheritdoc /> @@ -122,6 +128,7 @@ namespace Emby.Server.Implementations.SyncPlay throw new InvalidOperationException("Could not add session to group!"); } + UpdateSessionsCounter(session.UserId, 1); group.CreateGroup(session, request, cancellationToken); } } @@ -172,6 +179,7 @@ namespace Emby.Server.Implementations.SyncPlay if (existingGroup.GroupId.Equals(request.GroupId)) { // Restore session. + UpdateSessionsCounter(session.UserId, 1); group.SessionJoin(session, request, cancellationToken); return; } @@ -185,6 +193,7 @@ namespace Emby.Server.Implementations.SyncPlay throw new InvalidOperationException("Could not add session to group!"); } + UpdateSessionsCounter(session.UserId, 1); group.SessionJoin(session, request, cancellationToken); } } @@ -223,6 +232,7 @@ namespace Emby.Server.Implementations.SyncPlay throw new InvalidOperationException("Could not remove session from group!"); } + UpdateSessionsCounter(session.UserId, -1); group.SessionLeave(session, request, cancellationToken); if (group.IsGroupEmpty()) @@ -318,6 +328,19 @@ namespace Emby.Server.Implementations.SyncPlay } } + /// <inheritdoc /> + public bool IsUserActive(Guid userId) + { + if (_activeUsers.TryGetValue(userId, out var sessionsCounter)) + { + return sessionsCounter > 0; + } + else + { + return false; + } + } + /// <summary> /// Releases unmanaged and optionally managed resources. /// </summary> @@ -329,11 +352,11 @@ namespace Emby.Server.Implementations.SyncPlay return; } - _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; + _sessionManager.SessionControllerConnected -= OnSessionControllerConnected; _disposed = true; } - private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) + private void OnSessionControllerConnected(object sender, SessionEventArgs e) { var session = e.SessionInfo; @@ -343,5 +366,26 @@ namespace Emby.Server.Implementations.SyncPlay JoinGroup(session, request, CancellationToken.None); } } + + private void UpdateSessionsCounter(Guid userId, int toAdd) + { + // Update sessions counter. + var newSessionsCounter = _activeUsers.AddOrUpdate( + userId, + 1, + (key, sessionsCounter) => sessionsCounter + toAdd); + + // Should never happen. + if (newSessionsCounter < 0) + { + throw new InvalidOperationException("Sessions counter is negative!"); + } + + // Clean record if user has no more active sessions. + if (newSessionsCounter == 0) + { + _activeUsers.TryRemove(new KeyValuePair<Guid, int>(userId, newSessionsCounter)); + } + } } } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 447c587f9..f0734340b 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -75,8 +75,7 @@ namespace Emby.Server.Implementations.TV { parents = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !user.GetPreference(PreferenceKind.LatestItemExcludes) - .Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) + .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes).Contains(i.Id)) .ToArray(); } diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index ef346dd5d..ae2fa3ce1 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -12,7 +12,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; -using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; @@ -190,6 +189,22 @@ namespace Emby.Server.Implementations.Updates continue; } + for (var i = package.versions.Count - 1; i >= 0; i--) + { + // Remove versions with a target abi that is greater then the current application version. + if (Version.TryParse(package.versions[i].targetAbi, out var targetAbi) + && _applicationHost.ApplicationVersion < targetAbi) + { + package.versions.RemoveAt(i); + } + } + + // Don't add a package that doesn't have any compatible versions. + if (package.versions.Count == 0) + { + continue; + } + var existing = FilterPackages(result, package.name, packageGuid).FirstOrDefault(); if (existing != null) { @@ -407,6 +422,7 @@ namespace Emby.Server.Implementations.Updates using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); // CA5351: Do Not Use Broken Cryptographic Algorithms diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs index b5932ea6b..b898ac76c 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -3,6 +3,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.SyncPlay; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -13,20 +14,24 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy /// </summary> public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement> { + private readonly ISyncPlayManager _syncPlayManager; private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class. /// </summary> + /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> public SyncPlayAccessHandler( + ISyncPlayManager syncPlayManager, IUserManager userManager, INetworkManager networkManager, IHttpContextAccessor httpContextAccessor) : base(userManager, networkManager, httpContextAccessor) { + _syncPlayManager = syncPlayManager; _userManager = userManager; } @@ -42,10 +47,52 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy var userId = ClaimHelpers.GetUserId(context.User); var user = _userManager.GetUserById(userId!.Value); - if ((requirement.RequiredAccess.HasValue && user.SyncPlayAccess == requirement.RequiredAccess) - || user.SyncPlayAccess == SyncPlayAccess.CreateAndJoinGroups) + if (requirement.RequiredAccess == SyncPlayAccessRequirementType.HasAccess) { - context.Succeed(requirement); + if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups + || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups + || _syncPlayManager.IsUserActive(userId!.Value)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.CreateGroup) + { + if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.JoinGroup) + { + if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups + || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + } + else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup) + { + if (_syncPlayManager.IsUserActive(userId!.Value)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } } else { diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs index 7fcaf69f6..6fab4c0ad 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs @@ -11,23 +11,15 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy /// <summary> /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class. /// </summary> - /// <param name="requiredAccess">A value of <see cref="SyncPlayAccess"/>.</param> - public SyncPlayAccessRequirement(SyncPlayAccess requiredAccess) + /// <param name="requiredAccess">A value of <see cref="SyncPlayAccessRequirementType"/>.</param> + public SyncPlayAccessRequirement(SyncPlayAccessRequirementType requiredAccess) { RequiredAccess = requiredAccess; } /// <summary> - /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class. - /// </summary> - public SyncPlayAccessRequirement() - { - RequiredAccess = null; - } - - /// <summary> /// Gets the required SyncPlay access. /// </summary> - public SyncPlayAccess? RequiredAccess { get; } + public SyncPlayAccessRequirementType RequiredAccess { get; } } } diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index b35ceea1a..632dedb3c 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -51,13 +51,23 @@ namespace Jellyfin.Api.Constants public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; /// <summary> - /// Policy name for requiring access to SyncPlay. + /// Policy name for accessing SyncPlay. /// </summary> - public const string SyncPlayAccess = "SyncPlayAccess"; + public const string SyncPlayHasAccess = "SyncPlayHasAccess"; /// <summary> - /// Policy name for requiring group creation access to SyncPlay. + /// Policy name for creating a SyncPlay group. /// </summary> - public const string SyncPlayCreateGroupAccess = "SyncPlayCreateGroupAccess"; + public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; + + /// <summary> + /// Policy name for joining a SyncPlay group. + /// </summary> + public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; + + /// <summary> + /// Policy name for accessing a SyncPlay group. + /// </summary> + public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; } } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 8b8f63015..87b4577b6 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -12,6 +12,7 @@ using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers { @@ -22,14 +23,17 @@ namespace Jellyfin.Api.Controllers public class DisplayPreferencesController : BaseJellyfinApiController { private readonly IDisplayPreferencesManager _displayPreferencesManager; + private readonly ILogger<DisplayPreferencesController> _logger; /// <summary> /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. /// </summary> /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param> - public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager) + /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param> + public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger) { _displayPreferencesManager = displayPreferencesManager; + _logger = logger; } /// <summary> @@ -61,7 +65,6 @@ namespace Jellyfin.Api.Controllers { Client = displayPreferences.Client, Id = displayPreferences.ItemId.ToString(), - ViewType = itemPreferences.ViewType.ToString(), SortBy = itemPreferences.SortBy, SortOrder = itemPreferences.SortOrder, IndexBy = displayPreferences.IndexBy?.ToString(), @@ -77,16 +80,12 @@ namespace Jellyfin.Api.Controllers dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); } - foreach (var itemDisplayPreferences in _displayPreferencesManager.ListItemDisplayPreferences(displayPreferences.UserId, displayPreferences.Client)) - { - dto.CustomPrefs["landing-" + itemDisplayPreferences.ItemId] = itemDisplayPreferences.ViewType.ToString().ToLowerInvariant(); - } - dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant(); dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture); dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture); dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; + dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme; // Load all custom display preferences var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); @@ -189,10 +188,9 @@ namespace Jellyfin.Api.Controllers foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) { - if (Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var preferenceId)) + if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type)) { - var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, preferenceId, existingDisplayPreferences.Client); - itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType); + _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); displayPreferences.CustomPrefs.Remove(key); } } @@ -204,11 +202,6 @@ namespace Jellyfin.Api.Controllers itemPrefs.RememberSorting = displayPreferences.RememberSorting; itemPrefs.ItemId = itemId; - if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType)) - { - itemPrefs.ViewType = viewType; - } - // Set all remaining custom preferences. _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); _displayPreferencesManager.SaveChanges(); diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 4fd9c2fbf..694d16ad9 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -41,18 +41,25 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Description xml returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> [HttpGet("{serverId}/description")] [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult GetDescriptionXml([FromRoute, Required] string serverId) { - var url = GetAbsoluteUri(); - var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); - var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress); - return Ok(xml); + if (DlnaEntryPoint.Enabled) + { + var url = GetAbsoluteUri(); + var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); + var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress); + return Ok(xml); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -60,17 +67,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Dlna content directory returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns> [HttpGet("{serverId}/ContentDirectory")] [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")] [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetContentDirectory([FromRoute, Required] string serverId) { - return Ok(_contentDirectory.GetServiceXml()); + if (DlnaEntryPoint.Enabled) + { + return Ok(_contentDirectory.GetServiceXml()); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -78,17 +92,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Dlna media receiver registrar xml.</returns> [HttpGet("{serverId}/MediaReceiverRegistrar")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetMediaReceiverRegistrar([FromRoute, Required] string serverId) { - return Ok(_mediaReceiverRegistrar.GetServiceXml()); + if (DlnaEntryPoint.Enabled) + { + return Ok(_mediaReceiverRegistrar.GetServiceXml()); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -96,17 +117,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Dlna media receiver registrar xml.</returns> [HttpGet("{serverId}/ConnectionManager")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetConnectionManager([FromRoute, Required] string serverId) { - return Ok(_connectionManager.GetServiceXml()); + if (DlnaEntryPoint.Enabled) + { + return Ok(_connectionManager.GetServiceXml()); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -114,14 +142,21 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/ContentDirectory/Control")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + if (DlnaEntryPoint.Enabled) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -129,14 +164,21 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/ConnectionManager/Control")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + if (DlnaEntryPoint.Enabled) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -144,14 +186,21 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + if (DlnaEntryPoint.Enabled) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -159,17 +208,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) { - return ProcessEventRequest(_mediaReceiverRegistrar); + if (DlnaEntryPoint.Enabled) + { + return ProcessEventRequest(_mediaReceiverRegistrar); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -177,17 +233,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/ContentDirectory/Events")] [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) { - return ProcessEventRequest(_contentDirectory); + if (DlnaEntryPoint.Enabled) + { + return ProcessEventRequest(_contentDirectory); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -195,17 +258,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/ConnectionManager/Events")] [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [Produces(MediaTypeNames.Text.Xml)] [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) { - return ProcessEventRequest(_connectionManager); + if (DlnaEntryPoint.Enabled) + { + return ProcessEventRequest(_connectionManager); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -213,14 +283,24 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="serverId">Server UUID.</param> /// <param name="fileName">The icon filename.</param> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> /// <returns>Icon stream.</returns> [HttpGet("{serverId}/icons/{fileName}")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [ProducesImageFile] public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName) { - return GetIconInternal(fileName); + if (DlnaEntryPoint.Enabled) + { + return GetIconInternal(fileName); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } /// <summary> @@ -228,11 +308,22 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="fileName">The icon filename.</param> /// <returns>Icon stream.</returns> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> [HttpGet("icons/{fileName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] [ProducesImageFile] public ActionResult GetIcon([FromRoute, Required] string fileName) { - return GetIconInternal(fileName); + if (DlnaEntryPoint.Enabled) + { + return GetIconInternal(fileName); + } + + return StatusCode(StatusCodes.Status503ServiceUnavailable); } private ActionResult GetIconInternal(string fileName) diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 65de81d7a..c606d327c 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -98,7 +98,7 @@ namespace Jellyfin.Api.Controllers { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to update the image."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); } var user = _userManager.GetUserById(userId); @@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to update the image."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); } var user = _userManager.GetUserById(userId); @@ -190,7 +190,7 @@ namespace Jellyfin.Api.Controllers { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to delete the image."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); } var user = _userManager.GetUserById(userId); @@ -229,7 +229,7 @@ namespace Jellyfin.Api.Controllers { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to delete the image."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); } var user = _userManager.GetUserById(userId); @@ -325,9 +325,11 @@ namespace Jellyfin.Api.Controllers return NotFound(); } + await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + // Handle image/png; charset=utf-8 var mimeType = Request.ContentType.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); return NoContent(); @@ -358,9 +360,11 @@ namespace Jellyfin.Api.Controllers return NotFound(); } + await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + // Handle image/png; charset=utf-8 var mimeType = Request.ContentType.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); return NoContent(); diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 7e9035f80..b84136ac6 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -254,18 +254,18 @@ namespace Jellyfin.Api.Controllers includeItemTypes = new[] { "Playlist" }; } - bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id) + var enabledChannels = user!.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels); + + bool isInEnabledFolder = Array.IndexOf(user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders), item.Id) != -1 // Assume all folders inside an EnabledChannel are enabled - || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id) + || Array.IndexOf(enabledChannels, item.Id) != -1 // Assume all items inside an EnabledChannel are enabled - || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.ChannelId); + || Array.IndexOf(enabledChannels, item.ChannelId) != -1; var collectionFolders = _libraryManager.GetCollectionFolders(item); foreach (var collectionFolder in collectionFolders) { - if (user.GetPreference(PreferenceKind.EnabledFolders).Contains( - collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture), - StringComparer.OrdinalIgnoreCase)) + if (user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(collectionFolder.Id)) { isInEnabledFolder = true; } @@ -786,12 +786,12 @@ namespace Jellyfin.Api.Controllers var ancestorIds = Array.Empty<Guid>(); - var excludeFolderIds = user.GetPreference(PreferenceKind.LatestItemExcludes); + var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes); if (parentIdGuid.Equals(Guid.Empty) && excludeFolderIds.Length > 0) { ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !excludeFolderIds.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture))) + .Where(i => !excludeFolderIds.Contains(i.Id)) .Select(i => i.Id) .ToArray(); } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 184843b39..28d359ac3 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -667,7 +667,7 @@ namespace Jellyfin.Api.Controllers } // TODO determine non-ASCII validity. - return PhysicalFile(path, MimeTypes.GetMimeType(path)); + return PhysicalFile(path, MimeTypes.GetMimeType(path), filename); } /// <summary> @@ -742,8 +742,6 @@ namespace Jellyfin.Api.Controllers { Limit = limit, IncludeItemTypes = includeItemTypes.ToArray(), - IsMovie = isMovie, - IsSeries = isSeries, SimilarTo = item, DtoOptions = dtoOptions, EnableTotalRecordCount = !isMovie ?? true, diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 56d4b3933..6f2d43227 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -1119,20 +1119,15 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Set channel mappings. /// </summary> - /// <param name="providerId">Provider id.</param> - /// <param name="tunerChannelId">Tuner channel id.</param> - /// <param name="providerChannelId">Provider channel id.</param> + /// <param name="setChannelMappingDto">The set channel mapping dto.</param> /// <response code="200">Created channel mapping returned.</response> /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> [HttpPost("ChannelMappings")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping( - [FromQuery] string? providerId, - [FromQuery] string? tunerChannelId, - [FromQuery] string? providerChannelId) + public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) { - return await _liveTvManager.SetChannelMapping(providerId, tunerChannelId, providerChannelId).ConfigureAwait(false); + return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); } /// <summary> diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index a76dc057a..baa2e0636 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -17,6 +17,7 @@ using MediaBrowser.Model.MediaInfo; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers @@ -119,7 +120,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableTranscoding, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromBody] PlaybackInfoDto? playbackInfoDto) + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto) { var authInfo = _authContext.GetAuthorizationInfo(Request); @@ -258,24 +259,24 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? subtitleStreamIndex, [FromQuery] int? maxAudioChannels, [FromQuery] Guid? itemId, - [FromBody] OpenLiveStreamDto openLiveStreamDto, - [FromQuery] bool enableDirectPlay = true, - [FromQuery] bool enableDirectStream = true) + [FromBody] OpenLiveStreamDto? openLiveStreamDto, + [FromQuery] bool? enableDirectPlay, + [FromQuery] bool? enableDirectStream) { var request = new LiveStreamRequest { - OpenToken = openToken, - UserId = userId ?? Guid.Empty, - PlaySessionId = playSessionId, - MaxStreamingBitrate = maxStreamingBitrate, - StartTimeTicks = startTimeTicks, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - MaxAudioChannels = maxAudioChannels, - ItemId = itemId ?? Guid.Empty, + OpenToken = openToken ?? openLiveStreamDto?.OpenToken, + UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty, + PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId, + MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate, + StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks, + AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex, + MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels, + ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty, DeviceProfile = openLiveStreamDto?.DeviceProfile, - EnableDirectPlay = enableDirectPlay, - EnableDirectStream = enableDirectStream, + EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true, + EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true, DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } }; return await _mediaInfoHelper.OpenMediaSource(Request, request).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 3e55434c0..fcdad4bc7 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; @@ -17,6 +18,7 @@ using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Jellyfin.Api.Controllers { @@ -53,6 +55,13 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Creates a new playlist. /// </summary> + /// <remarks> + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// </remarks> + /// <param name="name">The playlist name.</param> + /// <param name="ids">The item ids.</param> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media type.</param> /// <param name="createPlaylistRequest">The create playlist payload.</param> /// <returns> /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. @@ -61,14 +70,23 @@ namespace Jellyfin.Api.Controllers [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( - [FromBody, Required] CreatePlaylistDto createPlaylistRequest) + [FromQuery] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] IReadOnlyList<Guid> ids, + [FromQuery] Guid? userId, + [FromQuery] string? mediaType, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) { + if (ids.Count == 0) + { + ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>(); + } + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { - Name = createPlaylistRequest.Name, - ItemIdList = createPlaylistRequest.Ids, - UserId = createPlaylistRequest.UserId, - MediaType = createPlaylistRequest.MediaType + Name = name ?? createPlaylistRequest?.Name, + ItemIdList = ids, + UserId = userId ?? createPlaylistRequest?.UserId ?? default, + MediaType = mediaType ?? createPlaylistRequest?.MediaType }).ConfigureAwait(false); return result; diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs index 73da2f906..4ac849181 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers { if (_quickConnect.State == QuickConnectState.Unavailable) { - return Forbid("Quick connect is unavailable"); + return StatusCode(StatusCodes.Status403Forbidden, "Quick connect is unavailable"); } _quickConnect.Activate(); @@ -126,7 +126,7 @@ namespace Jellyfin.Api.Controllers var userId = ClaimHelpers.GetUserId(Request.HttpContext.User); if (!userId.HasValue) { - return Forbid("Unknown user id"); + return StatusCode(StatusCodes.Status403Forbidden, "Unknown user id"); } return _quickConnect.AuthorizeRequest(userId.Value, code); diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index 471c9180d..82cbe58df 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -20,7 +20,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// The sync play controller. /// </summary> - [Authorize(Policy = Policies.SyncPlayAccess)] + [Authorize(Policy = Policies.SyncPlayHasAccess)] public class SyncPlayController : BaseJellyfinApiController { private readonly ISessionManager _sessionManager; @@ -51,7 +51,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("New")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayCreateGroupAccess)] + [Authorize(Policy = Policies.SyncPlayCreateGroup)] public ActionResult SyncPlayCreateGroup( [FromBody, Required] NewGroupRequestDto requestData) { @@ -69,7 +69,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Join")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayAccess)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] public ActionResult SyncPlayJoinGroup( [FromBody, Required] JoinGroupRequestDto requestData) { @@ -86,6 +86,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Leave")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayLeaveGroup() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); @@ -101,7 +102,7 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.SyncPlayAccess)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); @@ -117,6 +118,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("SetNewQueue")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlaySetNewQueue( [FromBody, Required] PlayRequestDto requestData) { @@ -137,6 +139,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("SetPlaylistItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlaySetPlaylistItem( [FromBody, Required] SetPlaylistItemRequestDto requestData) { @@ -154,6 +157,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("RemoveFromPlaylist")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayRemoveFromPlaylist( [FromBody, Required] RemoveFromPlaylistRequestDto requestData) { @@ -171,6 +175,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("MovePlaylistItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayMovePlaylistItem( [FromBody, Required] MovePlaylistItemRequestDto requestData) { @@ -188,6 +193,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Queue")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayQueue( [FromBody, Required] QueueRequestDto requestData) { @@ -204,6 +210,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Unpause")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayUnpause() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); @@ -219,6 +226,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Pause")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayPause() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); @@ -234,6 +242,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Stop")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayStop() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); @@ -250,6 +259,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Seek")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlaySeek( [FromBody, Required] SeekRequestDto requestData) { @@ -267,6 +277,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Buffering")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayBuffering( [FromBody, Required] BufferRequestDto requestData) { @@ -288,6 +299,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Ready")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayReady( [FromBody, Required] ReadyRequestDto requestData) { @@ -309,6 +321,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("SetIgnoreWait")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlaySetIgnoreWait( [FromBody, Required] IgnoreWaitRequestDto requestData) { @@ -326,6 +339,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("NextItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayNextItem( [FromBody, Required] NextItemRequestDto requestData) { @@ -343,6 +357,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("PreviousItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlayPreviousItem( [FromBody, Required] PreviousItemRequestDto requestData) { @@ -360,6 +375,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("SetRepeatMode")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlaySetRepeatMode( [FromBody, Required] SetRepeatModeRequestDto requestData) { @@ -377,6 +393,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("SetShuffleMode")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] public ActionResult SyncPlaySetShuffleMode( [FromBody, Required] SetShuffleModeRequestDto requestData) { diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 7784e8a11..e67a27ae3 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; +using System.Net; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; @@ -66,7 +67,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<SystemInfo> GetSystemInfo() { - return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress); + return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); } /// <summary> @@ -78,7 +79,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<PublicSystemInfo> GetPublicSystemInfo() { - return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress); + return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); } /// <summary> @@ -202,7 +203,7 @@ namespace Jellyfin.Api.Controllers // For older files, assume fully static var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare); - return File(stream, "text/plain"); + return File(stream, "text/plain; charset=utf-8"); } /// <summary> diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 9805b84b1..0f0bee4bc 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -133,11 +133,11 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteUser([FromRoute, Required] Guid userId) + public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId) { var user = _userManager.GetUserById(userId); _sessionManager.RevokeUserTokens(user.Id, null); - _userManager.DeleteUser(userId); + await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); return NoContent(); } @@ -169,7 +169,7 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw)) { - return Forbid("Only sha1 password is not allowed."); + return StatusCode(StatusCodes.Status403Forbidden, "Only sha1 password is not allowed."); } // Password should always be null @@ -267,11 +267,11 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UpdateUserPassword( [FromRoute, Required] Guid userId, - [FromBody] UpdateUserPassword request) + [FromBody, Required] UpdateUserPassword request) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to update the password."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); } var user = _userManager.GetUserById(userId); @@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers if (success == null) { - return Forbid("Invalid user or password entered."); + return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); } await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); @@ -325,11 +325,11 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateUserEasyPassword( [FromRoute, Required] Guid userId, - [FromBody] UpdateUserEasyPassword request) + [FromBody, Required] UpdateUserEasyPassword request) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) { - return Forbid("User is not allowed to update the easy password."); + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); } var user = _userManager.GetUserById(userId); @@ -367,16 +367,11 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task<ActionResult> UpdateUser( [FromRoute, Required] Guid userId, - [FromBody] UserDto updateUser) + [FromBody, Required] UserDto updateUser) { - if (updateUser == null) - { - return BadRequest(); - } - if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) { - return Forbid("User update not allowed."); + return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); } var user = _userManager.GetUserById(userId); @@ -407,13 +402,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task<ActionResult> UpdateUserPolicy( [FromRoute, Required] Guid userId, - [FromBody] UserPolicy newPolicy) + [FromBody, Required] UserPolicy newPolicy) { - if (newPolicy == null) - { - return BadRequest(); - } - var user = _userManager.GetUserById(userId); // If removing admin access @@ -421,14 +411,14 @@ namespace Jellyfin.Api.Controllers { if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) { - return Forbid("There must be at least one user in the system with administrative access."); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); } } // If disabling if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) { - return Forbid("Administrators cannot be disabled."); + return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); } // If disabling @@ -436,7 +426,7 @@ namespace Jellyfin.Api.Controllers { if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { - return Forbid("There must be at least one enabled user in the system."); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } var currentToken = _authContext.GetAuthorizationInfo(Request).Token; @@ -462,11 +452,11 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task<ActionResult> UpdateUserConfiguration( [FromRoute, Required] Guid userId, - [FromBody] UserConfiguration userConfig) + [FromBody, Required] UserConfiguration userConfig) { if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) { - return Forbid("User configuration update not allowed"); + return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); } await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); @@ -483,7 +473,7 @@ namespace Jellyfin.Api.Controllers [HttpPost("New")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<UserDto>> CreateUserByName([FromBody] CreateUserByName request) + public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request) { var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index d8bc9df1f..44dc63952 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -196,7 +196,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Merges videos into a single record. /// </summary> - /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param> + /// <param name="ids">Item id list. This allows multiple, comma delimited.</param> /// <response code="204">Videos merged.</response> /// <response code="400">Supply at least 2 video ids.</response> /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> @@ -204,9 +204,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds) + public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - var items = itemIds + var items = ids .Select(i => _libraryManager.GetItemById(i)) .OfType<Video>() .OrderBy(i => i.Id) diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index da6e5fa2d..f01f50cea 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -15,9 +15,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.1" /> <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" /> <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="5.6.3" /> diff --git a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs new file mode 100644 index 000000000..2ddaa89e8 --- /dev/null +++ b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Api.Models.LiveTvDtos +{ + /// <summary> + /// Set channel mapping dto. + /// </summary> + public class SetChannelMappingDto + { + /// <summary> + /// Gets or sets the provider id. + /// </summary> + [Required] + public string ProviderId { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the tuner channel id. + /// </summary> + [Required] + public string TunerChannelId { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the provider channel id. + /// </summary> + [Required] + public string ProviderChannelId { get; set; } = string.Empty; + } +}
\ No newline at end of file diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs index b0b3de855..704542326 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -11,6 +11,61 @@ namespace Jellyfin.Api.Models.MediaInfoDtos public class OpenLiveStreamDto { /// <summary> + /// Gets or sets the open token. + /// </summary> + public string? OpenToken { get; set; } + + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid? UserId { get; set; } + + /// <summary> + /// Gets or sets the play session id. + /// </summary> + public string? PlaySessionId { get; set; } + + /// <summary> + /// Gets or sets the max streaming bitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } + + /// <summary> + /// Gets or sets the start time in ticks. + /// </summary> + public long? StartTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the audio stream index. + /// </summary> + public int? AudioStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the subtitle stream index. + /// </summary> + public int? SubtitleStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the max audio channels. + /// </summary> + public int? MaxAudioChannels { get; set; } + + /// <summary> + /// Gets or sets the item id. + /// </summary> + public Guid? ItemId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct play. + /// </summary> + public bool? EnableDirectPlay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enale direct stream. + /// </summary> + public bool? EnableDirectStream { get; set; } + + /// <summary> /// Gets or sets the device profile. /// </summary> public DeviceProfile? DeviceProfile { get; set; } diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index d0d6889fc..65d4b644e 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -24,7 +24,7 @@ namespace Jellyfin.Api.Models.PlaylistDtos /// <summary> /// Gets or sets the user id. /// </summary> - public Guid UserId { get; set; } + public Guid? UserId { get; set; } /// <summary> /// Gets or sets the media type. diff --git a/Jellyfin.Data/Entities/ActivityLog.cs b/Jellyfin.Data/Entities/ActivityLog.cs index 620e82830..e2d5c7187 100644 --- a/Jellyfin.Data/Entities/ActivityLog.cs +++ b/Jellyfin.Data/Entities/ActivityLog.cs @@ -18,7 +18,8 @@ namespace Jellyfin.Data.Entities /// <param name="name">The name.</param> /// <param name="type">The type.</param> /// <param name="userId">The user id.</param> - public ActivityLog(string name, string type, Guid userId) + /// <param name="logLevel">The log level.</param> + public ActivityLog(string name, string type, Guid userId, LogLevel logLevel = LogLevel.Information) { if (string.IsNullOrEmpty(name)) { @@ -34,7 +35,7 @@ namespace Jellyfin.Data.Entities Type = type; UserId = userId; DateCreated = DateTime.UtcNow; - LogSeverity = LogLevel.Trace; + LogSeverity = logLevel; } /// <summary> diff --git a/Jellyfin.Data/Entities/ItemDisplayPreferences.cs b/Jellyfin.Data/Entities/ItemDisplayPreferences.cs index d81e4a31c..2b25bb25f 100644 --- a/Jellyfin.Data/Entities/ItemDisplayPreferences.cs +++ b/Jellyfin.Data/Entities/ItemDisplayPreferences.cs @@ -23,7 +23,6 @@ namespace Jellyfin.Data.Entities Client = client; SortBy = "SortName"; - ViewType = ViewType.Poster; SortOrder = SortOrder.Ascending; RememberSorting = false; RememberIndexing = false; diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index 6d4681914..362f3b4eb 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; @@ -71,7 +71,7 @@ namespace Jellyfin.Data.Entities EnableAutoLogin = false; PlayDefaultAudioTrack = true; SubtitleMode = SubtitlePlaybackMode.Default; - SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups; + SyncPlayAccess = SyncPlayUserAccessType.CreateAndJoinGroups; AddDefaultPermissions(); AddDefaultPreferences(); @@ -326,7 +326,7 @@ namespace Jellyfin.Data.Entities /// <summary> /// Gets or sets the level of sync play permissions this user has. /// </summary> - public SyncPlayAccess SyncPlayAccess { get; set; } + public SyncPlayUserAccessType SyncPlayAccess { get; set; } /// <summary> /// Gets or sets the row version. @@ -414,6 +414,44 @@ namespace Jellyfin.Data.Entities } /// <summary> + /// Gets the user's preferences for the given preference kind. + /// </summary> + /// <param name="preference">The preference kind.</param> + /// <typeparam name="T">Type of preference.</typeparam> + /// <returns>A {T} array containing the user's preference.</returns> + public T[] GetPreferenceValues<T>(PreferenceKind preference) + { + var val = Preferences.First(p => p.Kind == preference).Value; + if (string.IsNullOrEmpty(val)) + { + return Array.Empty<T>(); + } + + // Convert array of {string} to array of {T} + var converter = TypeDescriptor.GetConverter(typeof(T)); + var stringValues = val.Split(Delimiter); + var convertedCount = 0; + var parsedValues = new T[stringValues.Length]; + for (var i = 0; i < stringValues.Length; i++) + { + try + { + var parsedValue = converter.ConvertFromString(stringValues[i].Trim()); + if (parsedValue != null) + { + parsedValues[convertedCount++] = (T)parsedValue; + } + } + catch (FormatException) + { + // Unable to convert value + } + } + + return parsedValues[..convertedCount]; + } + + /// <summary> /// Sets the specified preference to the given value. /// </summary> /// <param name="preference">The preference kind.</param> @@ -421,7 +459,19 @@ namespace Jellyfin.Data.Entities public void SetPreference(PreferenceKind preference, string[] values) { Preferences.First(p => p.Kind == preference).Value - = string.Join(Delimiter.ToString(CultureInfo.InvariantCulture), values); + = string.Join(Delimiter, values); + } + + /// <summary> + /// Sets the specified preference to the given value. + /// </summary> + /// <param name="preference">The preference kind.</param> + /// <param name="values">The values.</param> + /// <typeparam name="T">The type of value.</typeparam> + public void SetPreference<T>(PreferenceKind preference, T[] values) + { + Preferences.First(p => p.Kind == preference).Value + = string.Join(Delimiter, values); } /// <summary> @@ -441,7 +491,7 @@ namespace Jellyfin.Data.Entities /// <returns><c>True</c> if the folder is in the user's grouped folders.</returns> public bool IsFolderGrouped(Guid id) { - return GetPreference(PreferenceKind.GroupedFolders).Any(i => new Guid(i) == id); + return Array.IndexOf(GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders), id) != -1; } private static bool IsParentalScheduleAllowed(AccessSchedule schedule, DateTime date) diff --git a/Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs b/Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs new file mode 100644 index 000000000..8c3e6cb17 --- /dev/null +++ b/Jellyfin.Data/Enums/SyncPlayAccessRequirementType.cs @@ -0,0 +1,28 @@ +namespace Jellyfin.Data.Enums +{ + /// <summary> + /// Enum SyncPlayAccessRequirementType. + /// </summary> + public enum SyncPlayAccessRequirementType + { + /// <summary> + /// User must have access to SyncPlay, in some form. + /// </summary> + HasAccess = 0, + + /// <summary> + /// User must be able to create groups. + /// </summary> + CreateGroup = 1, + + /// <summary> + /// User must be able to join groups. + /// </summary> + JoinGroup = 2, + + /// <summary> + /// User must be in a group. + /// </summary> + IsInGroup = 3 + } +} diff --git a/Jellyfin.Data/Enums/SyncPlayAccess.cs b/Jellyfin.Data/Enums/SyncPlayUserAccessType.cs index 8c13b37a1..030d16fb9 100644 --- a/Jellyfin.Data/Enums/SyncPlayAccess.cs +++ b/Jellyfin.Data/Enums/SyncPlayUserAccessType.cs @@ -1,9 +1,9 @@ namespace Jellyfin.Data.Enums { /// <summary> - /// Enum SyncPlayAccess. + /// Enum SyncPlayUserAccessType. /// </summary> - public enum SyncPlayAccess + public enum SyncPlayUserAccessType { /// <summary> /// User can create groups and join them. diff --git a/Jellyfin.Data/Enums/ViewType.cs b/Jellyfin.Data/Enums/ViewType.cs index 595429ab1..c0fd7d448 100644 --- a/Jellyfin.Data/Enums/ViewType.cs +++ b/Jellyfin.Data/Enums/ViewType.cs @@ -1,4 +1,4 @@ -namespace Jellyfin.Data.Enums +namespace Jellyfin.Data.Enums { /// <summary> /// An enum representing the type of view for a library or collection. @@ -6,33 +6,108 @@ public enum ViewType { /// <summary> - /// Shows banners. + /// Shows albums. /// </summary> - Banner = 0, + Albums = 0, /// <summary> - /// Shows a list of content. + /// Shows album artists. /// </summary> - List = 1, + AlbumArtists = 1, /// <summary> - /// Shows poster artwork. + /// Shows artists. /// </summary> - Poster = 2, + Artists = 2, /// <summary> - /// Shows poster artwork with a card containing the name and year. + /// Shows channels. /// </summary> - PosterCard = 3, + Channels = 3, /// <summary> - /// Shows a thumbnail. + /// Shows collections. /// </summary> - Thumb = 4, + Collections = 4, /// <summary> - /// Shows a thumbnail with a card containing the name and year. + /// Shows episodes. /// </summary> - ThumbCard = 5 + Episodes = 5, + + /// <summary> + /// Shows favorites. + /// </summary> + Favorites = 6, + + /// <summary> + /// Shows genres. + /// </summary> + Genres = 7, + + /// <summary> + /// Shows guide. + /// </summary> + Guide = 8, + + /// <summary> + /// Shows movies. + /// </summary> + Movies = 9, + + /// <summary> + /// Shows networks. + /// </summary> + Networks = 10, + + /// <summary> + /// Shows playlists. + /// </summary> + Playlists = 11, + + /// <summary> + /// Shows programs. + /// </summary> + Programs = 12, + + /// <summary> + /// Shows recordings. + /// </summary> + Recordings = 13, + + /// <summary> + /// Shows schedule. + /// </summary> + Schedule = 14, + + /// <summary> + /// Shows series. + /// </summary> + Series = 15, + + /// <summary> + /// Shows shows. + /// </summary> + Shows = 16, + + /// <summary> + /// Shows songs. + /// </summary> + Songs = 17, + + /// <summary> + /// Shows songs. + /// </summary> + Suggestions = 18, + + /// <summary> + /// Shows trailers. + /// </summary> + Trailers = 19, + + /// <summary> + /// Shows upcoming. + /// </summary> + Upcoming = 20 } } diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 89d6f4d9b..572038d00 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -41,8 +41,8 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.1" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.1" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs index ee60748c7..eab5777d5 100644 --- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -435,7 +435,7 @@ namespace Jellyfin.Drawing.Skia 0f, kernelOffset, SKShaderTileMode.Clamp, - false); + true); canvas.DrawBitmap( source, diff --git a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index 0e94f87f6..e9f9aad57 100644 --- a/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text.RegularExpressions; using SkiaSharp; namespace Jellyfin.Drawing.Skia @@ -118,6 +119,16 @@ namespace Jellyfin.Drawing.Skia }; canvas.DrawRect(0, 0, width, height, paintColor); + var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); + + // use the system fallback to find a typeface for the given CJK character + var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]"; + var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty); + if (!string.IsNullOrEmpty(filteredName)) + { + typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]); + } + // draw library name var textPaint = new SKPaint { @@ -125,7 +136,7 @@ namespace Jellyfin.Drawing.Skia Style = SKPaintStyle.Fill, TextSize = 112, TextAlign = SKTextAlign.Center, - Typeface = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright), + Typeface = typeFace, IsAntialias = true }; diff --git a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs index df420f48a..792e57f6a 100644 --- a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs +++ b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs @@ -28,6 +28,16 @@ namespace Jellyfin.Networking.Configuration public bool RequireHttps { get; set; } /// <summary> + /// Gets or sets the filesystem path of an X.509 certificate to use for SSL. + /// </summary> + public string CertificatePath { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>. + /// </summary> + public string CertificatePassword { get; set; } = string.Empty; + + /// <summary> /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at. /// </summary> public string BaseUrl @@ -83,7 +93,7 @@ namespace Jellyfin.Networking.Configuration /// </summary> /// <remarks> /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be - /// provided for <see cref="ServerConfiguration.CertificatePath"/> and <see cref="ServerConfiguration.CertificatePassword"/>. + /// provided for <see cref="CertificatePath"/> and <see cref="CertificatePassword"/>. /// </remarks> public bool EnableHttps { get; set; } diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs index a76ba49b6..60b899519 100644 --- a/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/Jellyfin.Networking/Manager/NetworkManager.cs @@ -913,15 +913,6 @@ namespace Jellyfin.Networking.Manager { string[] lanAddresses = config.LocalNetworkAddresses; - // TODO: remove when bug fixed: https://github.com/jellyfin/jellyfin-web/issues/1334 - - if (lanAddresses.Length == 1 && lanAddresses[0].IndexOf(',', StringComparison.OrdinalIgnoreCase) != -1) - { - lanAddresses = lanAddresses[0].Split(','); - } - - // TODO: end fix: https://github.com/jellyfin/jellyfin-web/issues/1334 - // Add virtual machine interface names to the list of bind exclusions, so that they are auto-excluded. if (config.IgnoreVirtualInterfaces) { @@ -1314,9 +1305,7 @@ namespace Jellyfin.Networking.Manager return true; } - // Have to return something, so return an internal address - - _logger.LogWarning("{Source}: External request received, however, no WAN interface found.", source); + _logger.LogDebug("{Source}: External request received, but no WAN interface found. Need to route through internal network.", source); return false; } } diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 7bde4f35b..27360afb0 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -27,7 +27,7 @@ namespace Jellyfin.Server.Implementations.Activity } /// <inheritdoc/> - public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated; + public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated; /// <inheritdoc/> public async Task CreateAsync(ActivityLog entry) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs index ec4a76e7f..0340248bb 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs @@ -86,7 +86,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session return name; } - private static string GetPlaybackNotificationType(string mediaType) + private static string? GetPlaybackNotificationType(string mediaType) { if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) { diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index a0bad29e9..1648b1b47 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -94,7 +94,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session return name; } - private static string GetPlaybackStoppedNotificationType(string mediaType) + private static string? GetPlaybackStoppedNotificationType(string mediaType) { if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) { diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index e663798da..9e4a2065f 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -5,6 +5,7 @@ <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <Nullable>enable</Nullable> </PropertyGroup> <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> @@ -25,11 +26,11 @@ <ItemGroup> <PackageReference Include="System.Linq.Async" Version="5.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.0"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.0"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs index 7f3f83749..39f842354 100644 --- a/Jellyfin.Server.Implementations/JellyfinDb.cs +++ b/Jellyfin.Server.Implementations/JellyfinDb.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index 662b4bf65..6a78e7ee6 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Linq; using System.Text; diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 334f27f85..9cc1c3e5e 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Collections.Generic; using System.IO; diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs index 1fb89c4a6..dbba80c21 100644 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs @@ -1,5 +1,4 @@ -#nullable enable -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System.Threading.Tasks; using Jellyfin.Data.Entities; diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs index 5f32479e1..c4e4c460a 100644 --- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs +++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Authentication; diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index f684d151d..d1de5408c 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,5 +1,4 @@ -#nullable enable -#pragma warning disable CA1307 +#pragma warning disable CA1307 using System; using System.Collections.Concurrent; @@ -220,7 +219,7 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc/> - public void DeleteUser(Guid userId) + public async Task DeleteUserAsync(Guid userId) { if (!_users.TryGetValue(userId, out var user)) { @@ -246,7 +245,7 @@ namespace Jellyfin.Server.Implementations.Users nameof(userId)); } - using var dbContext = _dbProvider.CreateContext(); + await using var dbContext = _dbProvider.CreateContext(); // Clear all entities related to the user from the database. if (user.ProfileImage != null) @@ -258,10 +257,10 @@ namespace Jellyfin.Server.Implementations.Users dbContext.RemoveRange(user.Preferences); dbContext.RemoveRange(user.AccessSchedules); dbContext.Users.Remove(user); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync().ConfigureAwait(false); _users.Remove(userId); - _eventManager.Publish(new UserDeletedEventArgs(user)); + await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } /// <inheritdoc/> @@ -376,14 +375,14 @@ namespace Jellyfin.Server.Implementations.Users EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing), AccessSchedules = user.AccessSchedules.ToArray(), BlockedTags = user.GetPreference(PreferenceKind.BlockedTags), - EnabledChannels = user.GetPreference(PreferenceKind.EnabledChannels)?.Select(Guid.Parse).ToArray(), + EnabledChannels = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels), EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices), - EnabledFolders = user.GetPreference(PreferenceKind.EnabledFolders)?.Select(Guid.Parse).ToArray(), + EnabledFolders = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders), EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders), SyncPlayAccess = user.SyncPlayAccess, - BlockedChannels = user.GetPreference(PreferenceKind.BlockedChannels)?.Select(Guid.Parse).ToArray(), - BlockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders)?.Select(Guid.Parse).ToArray(), - BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems).Select(Enum.Parse<UnratedItem>).ToArray() + BlockedChannels = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels), + BlockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders), + BlockUnratedItems = user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems) } }; } @@ -704,13 +703,11 @@ namespace Jellyfin.Server.Implementations.Users } // TODO: fix this at some point - user.SetPreference( - PreferenceKind.BlockUnratedItems, - policy.BlockUnratedItems?.Select(i => i.ToString()).ToArray() ?? Array.Empty<string>()); + user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); dbContext.Update(user); diff --git a/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs b/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs index 8a510898b..a9a18c823 100644 --- a/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs +++ b/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs @@ -13,9 +13,9 @@ namespace Jellyfin.Server.Implementations.ValueConverters /// </summary> /// <param name="kind">The kind to specify.</param> /// <param name="mappingHints">The mapping hints.</param> - public DateTimeKindValueConverter(DateTimeKind kind, ConverterMappingHints mappingHints = null) + public DateTimeKindValueConverter(DateTimeKind kind, ConverterMappingHints? mappingHints = null) : base(v => v.ToUniversalTime(), v => DateTime.SpecifyKind(v, kind), mappingHints) { } } -}
\ No newline at end of file +} diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 6bf6f383f..88e2b4152 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -107,5 +107,28 @@ namespace Jellyfin.Server.Extensions { return appBuilder.UseMiddleware<WebSocketHandlerMiddleware>(); } + + /// <summary> + /// Adds robots.txt redirection to the application pipeline. + /// </summary> + /// <param name="appBuilder">The application builder.</param> + /// <returns>The updated application builder.</returns> + public static IApplicationBuilder UseRobotsRedirection(this IApplicationBuilder appBuilder) + { + return appBuilder.UseMiddleware<RobotsRedirectionMiddleware>(); + } + + /// <summary> + /// Adds /emby and /mediabrowser route trimming to the application pipeline. + /// </summary> + /// <remarks> + /// This must be injected before any path related middleware. + /// </remarks> + /// <param name="appBuilder">The application builder.</param> + /// <returns>The updated application builder.</returns> + public static IApplicationBuilder UsePathTrim(this IApplicationBuilder appBuilder) + { + return appBuilder.UseMiddleware<LegacyEmbyRouteRewriteMiddleware>(); + } } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 74e7bb4b1..cd594b5c5 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; using Jellyfin.Server.Formatters; using MediaBrowser.Common.Json; +using MediaBrowser.Common.Net; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -127,18 +128,32 @@ namespace Jellyfin.Server.Extensions policy.AddRequirements(new RequiresElevationRequirement()); }); options.AddPolicy( - Policies.SyncPlayAccess, + Policies.SyncPlayHasAccess, policy => { policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.JoinGroups)); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.HasAccess)); }); options.AddPolicy( - Policies.SyncPlayCreateGroupAccess, + Policies.SyncPlayCreateGroup, policy => { policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.CreateAndJoinGroups)); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.CreateGroup)); + }); + options.AddPolicy( + Policies.SyncPlayJoinGroup, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.JoinGroup)); + }); + options.AddPolicy( + Policies.SyncPlayIsInGroup, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccessRequirementType.IsInGroup)); }); }); } @@ -169,11 +184,19 @@ namespace Jellyfin.Server.Extensions .Configure<ForwardedHeadersOptions>(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - for (var i = 0; i < knownProxies.Count; i++) + if (knownProxies.Count == 0) { - if (IPAddress.TryParse(knownProxies[i], out var address)) + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + } + else + { + for (var i = 0; i < knownProxies.Count; i++) { - options.KnownProxies.Add(address); + if (IPHost.TryParse(knownProxies[i], out var host)) + { + options.KnownProxies.Add(host.Address); + } } } }) diff --git a/Jellyfin.Server/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs index 7ad9466c1..eae9a8004 100644 --- a/Jellyfin.Server/Filters/FileResponseFilter.cs +++ b/Jellyfin.Server/Filters/FileResponseFilter.cs @@ -14,7 +14,8 @@ namespace Jellyfin.Server.Filters { Schema = new OpenApiSchema { - Type = "file" + Type = "string", + Format = "binary" } }; diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 03d06fdff..bc000fd45 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -40,8 +40,8 @@ <PackageReference Include="CommandLineParser" Version="2.8.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="5.0.0" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="5.0.1" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.1" /> <PackageReference Include="prometheus-net" Version="4.0.0" /> <PackageReference Include="prometheus-net.AspNetCore" Version="4.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" /> @@ -52,7 +52,6 @@ <PackageReference Include="Serilog.Sinks.File" Version="4.1.0" /> <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.2" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.4" /> - <PackageReference Include="SQLitePCLRaw.provider.sqlite3.netstandard11" Version="1.1.14" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs new file mode 100644 index 000000000..fdd8974d2 --- /dev/null +++ b/Jellyfin.Server/Middleware/LegacyEmbyRouteRewriteMiddleware.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Removes /emby and /mediabrowser from requested route. + /// </summary> + public class LegacyEmbyRouteRewriteMiddleware + { + private const string EmbyPath = "/emby"; + private const string MediabrowserPath = "/mediabrowser"; + + private readonly RequestDelegate _next; + private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class. + /// </summary> + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public LegacyEmbyRouteRewriteMiddleware( + RequestDelegate next, + ILogger<LegacyEmbyRouteRewriteMiddleware> logger) + { + _next = next; + _logger = logger; + } + + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase)) + { + httpContext.Request.Path = localPath[EmbyPath.Length..]; + _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath); + } + else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase)) + { + httpContext.Request.Path = localPath[MediabrowserPath.Length..]; + _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath); + } + + await _next(httpContext).ConfigureAwait(false); + } + } +}
\ No newline at end of file diff --git a/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs new file mode 100644 index 000000000..9d40d74fe --- /dev/null +++ b/Jellyfin.Server/Middleware/RobotsRedirectionMiddleware.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Redirect requests to robots.txt to web/robots.txt. + /// </summary> + public class RobotsRedirectionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger<RobotsRedirectionMiddleware> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class. + /// </summary> + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public RobotsRedirectionMiddleware( + RequestDelegate next, + ILogger<RobotsRedirectionMiddleware> logger) + { + _next = next; + _logger = logger; + } + + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); + httpContext.Response.Redirect("web/robots.txt"); + return; + } + + await _next(httpContext).ConfigureAwait(false); + } + } +}
\ No newline at end of file diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index af4be5a26..f4040748d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -8,7 +8,6 @@ using System.Text.Json.Serialization; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Server.Implementations; -using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -81,6 +80,8 @@ namespace Jellyfin.Server.Migrations.Routines { "unstable", ChromecastVersion.Unstable } }; + var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null)) { @@ -97,6 +98,15 @@ namespace Jellyfin.Server.Migrations.Routines var itemId = new Guid(result[1].ToBlob()); var dtoUserId = new Guid(result[1].ToBlob()); + var client = result[2].ToString(); + var displayPreferencesKey = $"{dtoUserId}|{itemId}|{client}"; + if (displayPrefs.Contains(displayPreferencesKey)) + { + // Duplicate display preference. + continue; + } + + displayPrefs.Add(displayPreferencesKey); var existingUser = _userManager.GetUserById(dtoUserId); if (existingUser == null) { @@ -109,7 +119,7 @@ namespace Jellyfin.Server.Migrations.Routines : ChromecastVersion.Stable; dto.CustomPrefs.Remove("chromecastVersion"); - var displayPreferences = new DisplayPreferences(dtoUserId, itemId, result[2].ToString()) + var displayPreferences = new DisplayPreferences(dtoUserId, itemId, client) { IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null, ShowBackdrop = dto.ShowBackdrop, @@ -185,7 +195,13 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var (key, value) in dto.CustomPrefs) { - dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value)); + // Custom display preferences can have a key collision. + var indexKey = $"{displayPreferences.UserId}|{itemId}|{displayPreferences.Client}|{key}"; + if (!customDisplayPrefs.Contains(indexKey)) + { + dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value)); + customDisplayPrefs.Add(indexKey); + } } dbContext.Add(displayPreferences); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 74c550331..33f039c39 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -168,9 +168,9 @@ namespace Jellyfin.Server.Migrations.Routines } user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray()); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index a1a7a3053..f05cdfe9b 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -598,7 +598,8 @@ namespace Jellyfin.Server .WriteTo.Async(x => x.File( Path.Combine(appPaths.LogDirectoryPath, "log_.log"), rollingInterval: RollingInterval.Day, - outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}")) + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", + encoding: Encoding.UTF8)) .Enrich.FromLogContext() .Enrich.WithThreadId() .CreateLogger(); diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index aa3ef5350..3395d2413 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -128,6 +128,8 @@ namespace Jellyfin.Server mainApp.UseHttpsRedirection(); } + // This must be injected before any path related middleware. + mainApp.UsePathTrim(); mainApp.UseStaticFiles(); if (appConfig.HostWebClient()) { @@ -142,6 +144,8 @@ namespace Jellyfin.Server RequestPath = "/web", ContentTypeProvider = extensionProvider }); + + mainApp.UseRobotsRedirection(); } mainApp.UseAuthentication(); diff --git a/MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs b/MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs new file mode 100644 index 000000000..b29e6a71a --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonBoolNumberConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Converts a number to a boolean. + /// This is needed for HDHomerun. + /// </summary> + public class JsonBoolNumberConverter : JsonConverter<bool> + { + /// <inheritdoc /> + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return Convert.ToBoolean(reader.GetInt32()); + } + + return reader.GetBoolean(); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } + } +}
\ No newline at end of file diff --git a/MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs b/MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs new file mode 100644 index 000000000..73e3a0493 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonDateTimeConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Legacy DateTime converter. + /// Milliseconds aren't output if zero by default. + /// </summary> + public class JsonDateTimeConverter : JsonConverter<DateTime> + { + /// <inheritdoc /> + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetDateTime(); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + if (value.Millisecond == 0) + { + // Remaining ticks value will be 0, manually format. + writer.WriteStringValue(value.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffZ", CultureInfo.InvariantCulture)); + } + else + { + writer.WriteStringValue(value); + } + } + } +}
\ No newline at end of file diff --git a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs index 52e08d071..bd9600110 100644 --- a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -13,21 +14,13 @@ namespace MediaBrowser.Common.Json.Converters public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var guidStr = reader.GetString(); - return guidStr == null ? Guid.Empty : new Guid(guidStr); } /// <inheritdoc /> public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options) { - if (value == Guid.Empty) - { - writer.WriteNullValue(); - } - else - { - writer.WriteStringValue(value); - } + writer.WriteStringValue(value.ToString("N", CultureInfo.InvariantCulture)); } } } diff --git a/MediaBrowser.Common/Json/Converters/JsonNullableGuidConverter.cs b/MediaBrowser.Common/Json/Converters/JsonNullableGuidConverter.cs new file mode 100644 index 000000000..6d96d5496 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonNullableGuidConverter.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Converts a GUID object or value to/from JSON. + /// </summary> + public class JsonNullableGuidConverter : JsonConverter<Guid?> + { + /// <inheritdoc /> + public override Guid? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var guidStr = reader.GetString(); + return guidStr == null ? null : new Guid(guidStr); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, Guid? value, JsonSerializerOptions options) + { + if (value == null || value == Guid.Empty) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.Value.ToString("N", CultureInfo.InvariantCulture)); + } + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonOmdbNotAvailableStringConverter.cs b/MediaBrowser.Common/Json/Converters/JsonOmdbNotAvailableStringConverter.cs new file mode 100644 index 000000000..4fec2ea3f --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonOmdbNotAvailableStringConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Converts a string <c>N/A</c> to <c>string.Empty</c>. + /// </summary> + public class JsonOmdbNotAvailableStringConverter : JsonConverter<string> + { + /// <inheritdoc /> + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + if (str != null && str.Equals("N/A", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return str; + } + + return JsonSerializer.Deserialize<string>(ref reader, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(value, options); + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonOmdbNotAvailableStructConverter.cs b/MediaBrowser.Common/Json/Converters/JsonOmdbNotAvailableStructConverter.cs new file mode 100644 index 000000000..b9e67ce2d --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonOmdbNotAvailableStructConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Converts a string <c>N/A</c> to <c>string.Empty</c>. + /// </summary> + /// <typeparam name="T">The resulting type.</typeparam> + public class JsonOmdbNotAvailableStructConverter<T> : JsonConverter<T?> + where T : struct + { + /// <inheritdoc /> + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + if (str != null && str.Equals("N/A", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + } + + return JsonSerializer.Deserialize<T>(ref reader, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(value, options); + } + } +} diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index c5050a21d..2ef24a884 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -20,53 +20,70 @@ namespace MediaBrowser.Common.Json public const string CamelCaseMediaType = "application/json; profile=\"CamelCase\""; /// <summary> + /// When changing these options, update + /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs + /// -> AddJellyfinApi + /// -> AddJsonOptions. + /// </summary> + private static readonly JsonSerializerOptions _jsonSerializerOptions = new () + { + ReadCommentHandling = JsonCommentHandling.Disallow, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + Converters = + { + new JsonGuidConverter(), + new JsonNullableGuidConverter(), + new JsonVersionConverter(), + new JsonStringEnumConverter(), + new JsonNullableStructConverterFactory(), + new JsonBoolNumberConverter(), + new JsonDateTimeConverter() + } + }; + + private static readonly JsonSerializerOptions _pascalCaseJsonSerializerOptions = new (_jsonSerializerOptions) + { + PropertyNamingPolicy = null + }; + + private static readonly JsonSerializerOptions _camelCaseJsonSerializerOptions = new (_jsonSerializerOptions) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// <summary> /// Gets the default <see cref="JsonSerializerOptions" /> options. /// </summary> /// <remarks> - /// When changing these options, update - /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs - /// -> AddJellyfinApi - /// -> AddJsonOptions. + /// The return value must not be modified. + /// If the defaults must be modified the author must use the copy constructor. /// </remarks> /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns> public static JsonSerializerOptions GetOptions() - { - var options = new JsonSerializerOptions - { - ReadCommentHandling = JsonCommentHandling.Disallow, - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - NumberHandling = JsonNumberHandling.AllowReadingFromString - }; - - options.Converters.Add(new JsonGuidConverter()); - options.Converters.Add(new JsonVersionConverter()); - options.Converters.Add(new JsonStringEnumConverter()); - options.Converters.Add(new JsonNullableStructConverterFactory()); - - return options; - } + => _jsonSerializerOptions; /// <summary> /// Gets camelCase json options. /// </summary> + /// <remarks> + /// The return value must not be modified. + /// If the defaults must be modified the author must use the copy constructor. + /// </remarks> /// <returns>The camelCase <see cref="JsonSerializerOptions" /> options.</returns> public static JsonSerializerOptions GetCamelCaseOptions() - { - var options = GetOptions(); - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - return options; - } + => _camelCaseJsonSerializerOptions; /// <summary> /// Gets PascalCase json options. /// </summary> + /// <remarks> + /// The return value must not be modified. + /// If the defaults must be modified the author must use the copy constructor. + /// </remarks> /// <returns>The PascalCase <see cref="JsonSerializerOptions" /> options.</returns> public static JsonSerializerOptions GetPascalCaseOptions() - { - var options = GetOptions(); - options.PropertyNamingPolicy = null; - return options; - } + => _pascalCaseJsonSerializerOptions; } } diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index be5e7f5b4..320e60dc6 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -14,6 +14,7 @@ </PropertyGroup> <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App"/> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> </ItemGroup> @@ -21,7 +22,6 @@ <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> - <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs index 085f769d0..31dd95402 100644 --- a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -48,10 +48,10 @@ namespace MediaBrowser.Controller.BaseItemManager return !baseItem.EnableMediaSourceDisplay; } - var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); + var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name); if (typeOptions != null) { - return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); + return typeOptions.MetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); } if (!libraryOptions.EnableInternetProviders) @@ -61,7 +61,7 @@ namespace MediaBrowser.Controller.BaseItemManager var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); - return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); + return itemConfig == null || !itemConfig.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); } /// <inheritdoc /> @@ -79,7 +79,7 @@ namespace MediaBrowser.Controller.BaseItemManager return !baseItem.EnableMediaSourceDisplay; } - var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); + var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name); if (typeOptions != null) { return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); diff --git a/MediaBrowser.Controller/Channels/Channel.cs b/MediaBrowser.Controller/Channels/Channel.cs index 129cdb519..b2315bda4 100644 --- a/MediaBrowser.Controller/Channels/Channel.cs +++ b/MediaBrowser.Controller/Channels/Channel.cs @@ -17,9 +17,10 @@ namespace MediaBrowser.Controller.Channels { public override bool IsVisible(User user) { - if (user.GetPreference(PreferenceKind.BlockedChannels) != null) + var blockedChannelsPreference = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels); + if (blockedChannelsPreference.Length != 0) { - if (user.GetPreference(PreferenceKind.BlockedChannels).Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + if (blockedChannelsPreference.Contains(Id)) { return false; } @@ -27,8 +28,7 @@ namespace MediaBrowser.Controller.Channels else { if (!user.HasPermission(PermissionKind.EnableAllChannels) - && !user.GetPreference(PreferenceKind.EnabledChannels) - .Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + && !user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels).Contains(Id)) { return false; } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index 48cd9371a..9a33ad9d7 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -120,7 +120,7 @@ namespace MediaBrowser.Controller.Entities.Audio protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music); } public override UnratedItem GetBlockUnratedType() diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index c5e50cf45..8a9bb12c7 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -145,7 +145,7 @@ namespace MediaBrowser.Controller.Entities.Audio protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Music); } public override UnratedItem GetBlockUnratedType() diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index d8fad3bfb..cbb02aabd 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -480,11 +480,11 @@ namespace MediaBrowser.Controller.Entities return true; } - var allowed = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders); + var allowed = user.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders); if (SourceType == SourceType.Channel) { - return allowed.Contains(ChannelId.ToString(""), StringComparer.OrdinalIgnoreCase); + return allowed.Contains(ChannelId); } else { @@ -492,7 +492,7 @@ namespace MediaBrowser.Controller.Entities foreach (var folder in collectionFolders) { - if (allowed.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + if (allowed.Contains(folder.Id)) { return true; } @@ -1385,6 +1385,7 @@ namespace MediaBrowser.Controller.Entities new List<FileSystemMetadata>(); var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false); + await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh if (ownedItemsChanged) { @@ -1908,7 +1909,7 @@ namespace MediaBrowser.Controller.Entities return false; } - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(GetBlockUnratedType().ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(GetBlockUnratedType()); } /// <summary> diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index d25545a2f..c3b6af76e 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -4,9 +4,11 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common.Json; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -24,10 +26,9 @@ namespace MediaBrowser.Controller.Entities /// </summary> public class CollectionFolder : Folder, ICollectionFolder { + private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); public static IXmlSerializer XmlSerializer { get; set; } - public static IJsonSerializer JsonSerializer { get; set; } - public static IServerApplicationHost ApplicationHost { get; set; } public CollectionFolder() @@ -122,7 +123,7 @@ namespace MediaBrowser.Controller.Entities { LibraryOptions[path] = options; - var clone = JsonSerializer.DeserializeFromString<LibraryOptions>(JsonSerializer.SerializeToString(options)); + var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.Serialize(options, _jsonOptions), _jsonOptions); foreach (var mediaPath in clone.PathInfos) { if (!string.IsNullOrEmpty(mediaPath.Path)) diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 23f4c00c1..cac5026f7 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -186,13 +186,10 @@ namespace MediaBrowser.Controller.Entities { if (this is ICollectionFolder && !(this is BasePluginFolder)) { - var blockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders); + var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders); if (blockedMediaFolders.Length > 0) { - if (blockedMediaFolders.Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase) || - - // Backwards compatibility - blockedMediaFolders.Contains(Name, StringComparer.OrdinalIgnoreCase)) + if (blockedMediaFolders.Contains(Id)) { return false; } @@ -200,8 +197,7 @@ namespace MediaBrowser.Controller.Entities else { if (!user.HasPermission(PermissionKind.EnableAllFolders) - && !user.GetPreference(PreferenceKind.EnabledFolders) - .Contains(Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase)) + && !user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(Id)) { return false; } @@ -354,6 +350,11 @@ namespace MediaBrowser.Controller.Entities { await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); } + else + { + // metadata is up-to-date; make sure DB has correct images dimensions and hash + await LibraryManager.UpdateImagesAsync(currentChild).ConfigureAwait(false); + } continue; } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 8de88cc1b..05e4229ca 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -49,7 +49,7 @@ namespace MediaBrowser.Controller.Entities.Movies protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Movie.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Movie); } public override double GetDefaultPrimaryImageAspectRatio() diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index e8afa9a49..1a379074d 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -452,7 +452,7 @@ namespace MediaBrowser.Controller.Entities.TV protected override bool GetBlockUnratedValue(User user) { - return user.GetPreference(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Series.ToString()); + return user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).Contains(UnratedItem.Series); } public override UnratedItem GetBlockUnratedType() diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 24b101694..6700761fc 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -571,7 +571,7 @@ namespace MediaBrowser.Controller.Library string videoPath, string[] files); - void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason); + Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason); BaseItem GetParentItem(string parentId, Guid? userId); diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 8fd3b8c34..6e267834b 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -93,7 +93,8 @@ namespace MediaBrowser.Controller.Library /// Deletes the specified user. /// </summary> /// <param name="userId">The id of the user to be deleted.</param> - void DeleteUser(Guid userId); + /// <returns>A task representing the deletion of the user.</returns> + Task DeleteUserAsync(Guid userId); /// <summary> /// Resets the password. diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 91a03e647..efab87a38 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1127,13 +1127,25 @@ namespace MediaBrowser.Controller.MediaEncoding targetVideoCodec = "hevc"; } - var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault(); - profile = Regex.Replace(profile, @"\s+", String.Empty); + var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault() ?? string.Empty; + profile = Regex.Replace(profile, @"\s+", string.Empty); + + // We only transcode to HEVC 8-bit for now, force Main Profile. + if (profile.Contains("main10", StringComparison.OrdinalIgnoreCase) + || profile.Contains("mainstill", StringComparison.OrdinalIgnoreCase)) + { + profile = "main"; + } + + // Extended Profile is not supported by any known h264 encoders, force Main Profile. + if (profile.Contains("extended", StringComparison.OrdinalIgnoreCase)) + { + profile = "main"; + } // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile. if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) - && profile != null - && profile.IndexOf("high 10", StringComparison.OrdinalIgnoreCase) != -1) + && profile.Contains("high10", StringComparison.OrdinalIgnoreCase)) { profile = "high"; } @@ -1141,8 +1153,7 @@ namespace MediaBrowser.Controller.MediaEncoding // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case, // which is compatible (and ugly). if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - && profile != null - && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1) + && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase)) { profile = "constrained_baseline"; } @@ -1151,16 +1162,36 @@ namespace MediaBrowser.Controller.MediaEncoding if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)) - && profile != null - && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1) + && profile.Contains("baseline", StringComparison.OrdinalIgnoreCase)) { profile = "baseline"; } + // libx264, h264_qsv, h264_nvenc and h264_vaapi does not support Constrained High profile, force High in this case. + if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)) + && profile.Contains("high", StringComparison.OrdinalIgnoreCase)) + { + profile = "high"; + } + + if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + && profile.Contains("constrainedbaseline", StringComparison.OrdinalIgnoreCase)) + { + profile = "constrained_baseline"; + } + + if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + && profile.Contains("constrainedhigh", StringComparison.OrdinalIgnoreCase)) + { + profile = "constrained_high"; + } + // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile. - if (!string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) - && profile != null - && profile.IndexOf("main 10", StringComparison.OrdinalIgnoreCase) != -1) + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) + && profile.Contains("main10", StringComparison.OrdinalIgnoreCase)) { profile = "main"; } @@ -1691,6 +1722,16 @@ namespace MediaBrowser.Controller.MediaEncoding : transcoderChannelLimit.Value; } + // Avoid transcoding to audio channels other than 1ch, 2ch, 6ch (5.1 layout) and 8ch (7.1 layout). + // https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices + if (isTranscodingAudio + && state.TranscodingType != TranscodingJobType.Progressive + && resultChannels.HasValue + && (resultChannels.Value > 2 && resultChannels.Value < 6 || resultChannels.Value == 7)) + { + resultChannels = 2; + } + return resultChannels; } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 9ad8557ce..6c06dcad5 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -47,6 +47,11 @@ namespace MediaBrowser.Controller.Session event EventHandler<SessionEventArgs> SessionActivity; /// <summary> + /// Occurs when [session controller connected]. + /// </summary> + event EventHandler<SessionEventArgs> SessionControllerConnected; + + /// <summary> /// Occurs when [capabilities changed]. /// </summary> event EventHandler<SessionEventArgs> CapabilitiesChanged; @@ -78,6 +83,12 @@ namespace MediaBrowser.Controller.Session /// <param name="user">The user.</param> SessionInfo LogSessionActivity(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, Jellyfin.Data.Entities.User user); + /// <summary> + /// Used to report that a session controller has connected. + /// </summary> + /// <param name="session">The session.</param> + void OnSessionControllerConnected(SessionInfo session); + void UpdateDeviceName(string sessionId, string reportedDeviceName); /// <summary> diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs index d0244563a..1c954828c 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs @@ -51,5 +51,12 @@ namespace MediaBrowser.Controller.SyncPlay /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken); + + /// <summary> + /// Checks whether a user has an active session using SyncPlay. + /// </summary> + /// <param name="userId">The user identifier to check.</param> + /// <returns><c>true</c> if the user is using SyncPlay; <c>false</c> otherwise.</returns> + bool IsUserActive(Guid userId); } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index b1da9c712..fbd08a97c 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -603,16 +603,19 @@ namespace MediaBrowser.MediaEncoding.Encoder } // Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case. + // mpegts need larger batch size otherwise the corrupted thumbnail will be created. Larger batch size will lower the processing speed. var enableThumbnail = useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase); if (enableThumbnail) { + var useLargerBatchSize = string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase); + var batchSize = useLargerBatchSize ? "50" : "24"; if (string.IsNullOrEmpty(vf)) { - vf = "-vf thumbnail=24"; + vf = "-vf thumbnail=" + batchSize; } else { - vf += ",thumbnail=24"; + vf += ",thumbnail=" + batchSize; } } diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 7bb2a7d03..f8af499e4 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -24,7 +24,7 @@ <ItemGroup> <PackageReference Include="BDInfo" Version="0.7.6.1" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" /> <PackageReference Include="UTF.Unknown" Version="2.3.0" /> </ItemGroup> diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 100756c24..38b333510 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -88,11 +88,11 @@ namespace MediaBrowser.Model.Configuration // The left side of the dot is the platform number, and the right side is the device number on the platform. OpenclDevice = "0.0"; EnableTonemapping = false; - TonemappingAlgorithm = "reinhard"; + TonemappingAlgorithm = "hable"; TonemappingRange = "auto"; TonemappingDesat = 0; TonemappingThreshold = 0.8; - TonemappingPeak = 0; + TonemappingPeak = 100; TonemappingParam = 0; H264Crf = 23; H265Crf = 28; diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 0dbd51bdc..9fb978e9b 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -49,8 +49,6 @@ namespace MediaBrowser.Model.Configuration new MetadataOptions { ItemType = "Series", - DisabledMetadataFetchers = new[] { "TheMovieDb" }, - DisabledImageFetchers = new[] { "TheMovieDb" } }, new MetadataOptions { @@ -69,13 +67,10 @@ namespace MediaBrowser.Model.Configuration new MetadataOptions { ItemType = "Season", - DisabledMetadataFetchers = new[] { "TheMovieDb" }, }, new MetadataOptions { ItemType = "Episode", - DisabledMetadataFetchers = new[] { "The Open Movie Database", "TheMovieDb" }, - DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" } } }; } @@ -304,6 +299,18 @@ namespace MediaBrowser.Model.Configuration public int MinResumeDurationSeconds { get; set; } = 300; /// <summary> + /// Gets or sets the minimum minutes of a book that must be played in order for playstate to be updated. + /// </summary> + /// <value>The min resume in minutes.</value> + public int MinAudiobookResume { get; set; } = 5; + + /// <summary> + /// Gets or sets the remaining minutes of a book that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. + /// </summary> + /// <value>The remaining time in minutes.</value> + public int MaxAudiobookResume { get; set; } = 5; + + /// <summary> /// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several /// different directories and files. diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 59c981000..431cf0baf 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1033,9 +1033,9 @@ namespace MediaBrowser.Model.Dlna { _logger.LogInformation( "Profile: {0}, No video direct play profiles found for {1} with codec {2}", - profile.Name ?? "Unknown Profile", - mediaSource.Path ?? "Unknown path", - videoStream.Codec ?? "Unknown codec"); + profile?.Name ?? "Unknown Profile", + mediaSource?.Path ?? "Unknown path", + videoStream?.Codec ?? "Unknown codec"); return (null, GetTranscodeReasonsFromDirectPlayProfile(mediaSource, videoStream, audioStream, profile.DirectPlayProfiles)); } diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index fac754177..3f7aac9cd 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -152,7 +152,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the channel identifier. /// </summary> /// <value>The channel identifier.</value> - public Guid ChannelId { get; set; } + public Guid? ChannelId { get; set; } public string ChannelName { get; set; } @@ -270,7 +270,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the parent id. /// </summary> /// <value>The parent id.</value> - public Guid ParentId { get; set; } + public Guid? ParentId { get; set; } /// <summary> /// Gets or sets the type. @@ -344,13 +344,13 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the series id. /// </summary> /// <value>The series id.</value> - public Guid SeriesId { get; set; } + public Guid? SeriesId { get; set; } /// <summary> /// Gets or sets the season identifier. /// </summary> /// <value>The season identifier.</value> - public Guid SeasonId { get; set; } + public Guid? SeasonId { get; set; } /// <summary> /// Gets or sets the special feature count. @@ -428,7 +428,7 @@ namespace MediaBrowser.Model.Dto /// Gets or sets the album id. /// </summary> /// <value>The album id.</value> - public Guid AlbumId { get; set; } + public Guid? AlbumId { get; set; } /// <summary> /// Gets or sets the album image tag. diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 1782b42e2..98097477c 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -49,7 +49,7 @@ namespace MediaBrowser.Model.Entities } instance.ProviderIds.TryGetValue(name, out string? id); - return id; + return string.IsNullOrEmpty(id) ? null : id; } /// <summary> diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index b86187f9b..334fe8209 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -33,7 +33,6 @@ <ItemGroup> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> - <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" /> <PackageReference Include="System.Globalization" Version="4.3.0" /> <PackageReference Include="System.Text.Json" Version="5.0.0" /> diff --git a/MediaBrowser.Model/Serialization/IJsonSerializer.cs b/MediaBrowser.Model/Serialization/IJsonSerializer.cs deleted file mode 100644 index 09b6ff9b5..000000000 --- a/MediaBrowser.Model/Serialization/IJsonSerializer.cs +++ /dev/null @@ -1,102 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Threading.Tasks; - -namespace MediaBrowser.Model.Serialization -{ - public interface IJsonSerializer - { - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - void SerializeToStream(object obj, Stream stream); - - /// <summary> - /// Serializes to stream. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="stream">The stream.</param> - /// <exception cref="ArgumentNullException">obj</exception> - void SerializeToStream<T>(T obj, Stream stream); - - /// <summary> - /// Serializes to file. - /// </summary> - /// <param name="obj">The obj.</param> - /// <param name="file">The file.</param> - /// <exception cref="ArgumentNullException">obj</exception> - void SerializeToFile(object obj, string file); - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <param name="type">The type.</param> - /// <param name="file">The file.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">type</exception> - object DeserializeFromFile(Type type, string file); - - /// <summary> - /// Deserializes from file. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="file">The file.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">file</exception> - T DeserializeFromFile<T>(string file) - where T : class; - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="stream">The stream.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - T DeserializeFromStream<T>(Stream stream); - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="text">The text.</param> - /// <returns>``0.</returns> - /// <exception cref="ArgumentNullException">text</exception> - T DeserializeFromString<T>(string text); - - /// <summary> - /// Deserializes from stream. - /// </summary> - /// <param name="stream">The stream.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">stream</exception> - object DeserializeFromStream(Stream stream, Type type); - - /// <summary> - /// Deserializes from string. - /// </summary> - /// <param name="json">The json.</param> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> - /// <exception cref="ArgumentNullException">json</exception> - object DeserializeFromString(string json, Type type); - - /// <summary> - /// Serializes to string. - /// </summary> - /// <param name="obj">The obj.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">obj</exception> - string SerializeToString(object obj); - - Task<object> DeserializeFromStreamAsync(Stream stream, Type type); - Task<T> DeserializeFromStreamAsync<T>(Stream stream); - } -} diff --git a/MediaBrowser.Model/Users/UserPolicy.cs b/MediaBrowser.Model/Users/UserPolicy.cs index 363b2633f..37da04adf 100644 --- a/MediaBrowser.Model/Users/UserPolicy.cs +++ b/MediaBrowser.Model/Users/UserPolicy.cs @@ -111,7 +111,7 @@ namespace MediaBrowser.Model.Users /// Gets or sets a value indicating what SyncPlay features the user can access. /// </summary> /// <value>Access level to SyncPlay features.</value> - public SyncPlayAccess SyncPlayAccess { get; set; } + public SyncPlayUserAccessType SyncPlayAccess { get; set; } public UserPolicy() { @@ -160,7 +160,7 @@ namespace MediaBrowser.Model.Users EnableContentDownloading = true; EnablePublicSharing = true; EnableRemoteAccess = true; - SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups; + SyncPlayAccess = SyncPlayUserAccessType.CreateAndJoinGroups; } } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 6dbce3067..8b3ca17ca 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -229,7 +229,7 @@ namespace MediaBrowser.Providers.Manager await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); } - private Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken) + private async Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken) { var personsToSave = new List<BaseItem>(); @@ -239,6 +239,7 @@ namespace MediaBrowser.Providers.Manager if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl)) { + var itemUpdateType = ItemUpdateType.MetadataDownload; var saveEntity = false; var personEntity = LibraryManager.GetPerson(person.Name); foreach (var id in person.ProviderIds) @@ -261,18 +262,18 @@ namespace MediaBrowser.Providers.Manager 0); saveEntity = true; + itemUpdateType = ItemUpdateType.ImageUpdate; } if (saveEntity) { personsToSave.Add(personEntity); + await LibraryManager.RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); } } } - LibraryManager.RunMetadataSavers(personsToSave, ItemUpdateType.MetadataDownload); LibraryManager.CreateItems(personsToSave, null, CancellationToken.None); - return Task.CompletedTask; } protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index accdea36e..071a149db 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -19,10 +19,10 @@ <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="OptimizedPriorityQueue" Version="5.0.0" /> <PackageReference Include="PlaylistsNET" Version="1.1.3" /> <PackageReference Include="TMDbLib" Version="1.7.3-alpha" /> - <PackageReference Include="TvDbSharper" Version="3.2.2" /> </ItemGroup> <PropertyGroup> diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs index 293087da7..cd9e47743 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs @@ -1,9 +1,12 @@ #pragma warning disable CS1591 using System.Collections.Generic; +using System.IO; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -19,13 +22,12 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); - public AudioDbAlbumImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory, IJsonSerializer json) + public AudioDbAlbumImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory) { _config = config; _httpClientFactory = httpClientFactory; - _json = json; } /// <inheritdoc /> @@ -56,7 +58,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbAlbumProvider.GetAlbumInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<AudioDbAlbumProvider.RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<AudioDbAlbumProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.album != null && obj.album.Count > 0) { diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs index 97bba10ba..f463a3566 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs @@ -6,10 +6,12 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; @@ -27,16 +29,15 @@ namespace MediaBrowser.Providers.Plugins.AudioDb private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); public static AudioDbAlbumProvider Current; - public AudioDbAlbumProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory, IJsonSerializer json) + public AudioDbAlbumProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory) { _config = config; _fileSystem = fileSystem; _httpClientFactory = httpClientFactory; - _json = json; Current = this; } @@ -64,7 +65,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetAlbumInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.album != null && obj.album.Count > 0) { diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs index d250acfa8..36700d191 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs @@ -1,9 +1,12 @@ #pragma warning disable CS1591 using System.Collections.Generic; +using System.IO; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -19,12 +22,11 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); - public AudioDbArtistImageProvider(IServerConfigurationManager config, IJsonSerializer json, IHttpClientFactory httpClientFactory) + public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory) { _config = config; - _json = json; _httpClientFactory = httpClientFactory; } @@ -58,7 +60,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<AudioDbArtistProvider.RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.artists != null && obj.artists.Count > 0) { diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs index a2a03e1f9..7a15adb8e 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs @@ -5,10 +5,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; @@ -29,14 +31,13 @@ namespace MediaBrowser.Providers.Plugins.AudioDb private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _json; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.GetOptions(); - public AudioDbArtistProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory, IJsonSerializer json) + public AudioDbArtistProvider(IServerConfigurationManager config, IFileSystem fileSystem, IHttpClientFactory httpClientFactory) { _config = config; _fileSystem = fileSystem; _httpClientFactory = httpClientFactory; - _json = json; Current = this; } @@ -65,7 +66,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetArtistInfoPath(_config.ApplicationPaths, id); - var obj = _json.DeserializeFromFile<RootObject>(path); + await using FileStream jsonStream = File.OpenRead(path); + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (obj != null && obj.artists != null && obj.artists.Count > 0) { diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index bfc840ea5..24ef80a35 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -12,13 +12,11 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder { - private readonly IJsonSerializer _jsonSerializer; private readonly IHttpClientFactory _httpClientFactory; private readonly OmdbItemProvider _itemProvider; private readonly IFileSystem _fileSystem; @@ -26,19 +24,17 @@ namespace MediaBrowser.Providers.Plugins.Omdb private readonly IApplicationHost _appHost; public OmdbEpisodeProvider( - IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; _appHost = appHost; - _itemProvider = new OmdbItemProvider(jsonSerializer, _appHost, httpClientFactory, libraryManager, fileSystem, configurationManager); + _itemProvider = new OmdbItemProvider(_appHost, httpClientFactory, libraryManager, fileSystem, configurationManager); } // After TheTvDb @@ -69,7 +65,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { if (info.IndexNumber.HasValue && info.ParentIndexNumber.HasValue) { - result.HasMetadata = await new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager) + result.HasMetadata = await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager) .FetchEpisodeData(result, info.IndexNumber.Value, info.ParentIndexNumber.Value, info.GetProviderId(MetadataProvider.Imdb), seriesImdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs index 8f4240dc1..df67aff31 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs @@ -1,8 +1,8 @@ #pragma warning disable CS1591 using System.Collections.Generic; -using System.Net.Http; using System.Globalization; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; @@ -15,21 +15,18 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; - private readonly IJsonSerializer _jsonSerializer; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IApplicationHost _appHost; - public OmdbImageProvider(IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) + public OmdbImageProvider(IApplicationHost appHost, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; @@ -56,7 +53,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var list = new List<RemoteImageInfo>(); - var provider = new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager); + var provider = new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager); if (!string.IsNullOrWhiteSpace(imdbId)) { diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 43d8af75f..71d551063 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -6,9 +6,12 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; +using MediaBrowser.Common.Json; +using MediaBrowser.Common.Json.Converters; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -19,34 +22,35 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbItemProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IRemoteMetadataProvider<Movie, MovieInfo>, IRemoteMetadataProvider<Trailer, TrailerInfo>, IHasOrder { - private readonly IJsonSerializer _jsonSerializer; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IApplicationHost _appHost; + private readonly JsonSerializerOptions _jsonOptions; public OmdbItemProvider( - IJsonSerializer jsonSerializer, IApplicationHost appHost, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, IFileSystem fileSystem, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; _fileSystem = fileSystem; _configurationManager = configurationManager; _appHost = appHost; + + _jsonOptions = new JsonSerializerOptions(JsonDefaults.GetOptions()); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStructConverter<int>()); } public string Name => "The Open Movie Database"; @@ -138,7 +142,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (isSearch) { - var searchResultList = await _jsonSerializer.DeserializeFromStreamAsync<SearchResultList>(stream).ConfigureAwait(false); + var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (searchResultList != null && searchResultList.Search != null) { resultList.AddRange(searchResultList.Search); @@ -146,7 +150,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } else { - var result = await _jsonSerializer.DeserializeFromStreamAsync<SearchResult>(stream).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (string.Equals(result.Response, "true", StringComparison.OrdinalIgnoreCase)) { resultList.Add(result); @@ -221,7 +225,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); result.HasMetadata = true; - await new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } return result; @@ -253,7 +257,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb result.Item.SetProviderId(MetadataProvider.Imdb, imdbId); result.HasMetadata = true; - await new OmdbProvider(_jsonSerializer, _httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + await new OmdbProvider(_httpClientFactory, _fileSystem, _appHost, _configurationManager).Fetch(result, imdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); } return result; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index e540e4471..2372e3183 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -7,35 +7,41 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; +using MediaBrowser.Common.Json; +using MediaBrowser.Common.Json.Converters; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; namespace MediaBrowser.Providers.Plugins.Omdb { public class OmdbProvider { - private readonly IJsonSerializer _jsonSerializer; private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly IApplicationHost _appHost; + private readonly JsonSerializerOptions _jsonOptions; - public OmdbProvider(IJsonSerializer jsonSerializer, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IApplicationHost appHost, IServerConfigurationManager configurationManager) + public OmdbProvider(IHttpClientFactory httpClientFactory, IFileSystem fileSystem, IApplicationHost appHost, IServerConfigurationManager configurationManager) { - _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _fileSystem = fileSystem; _configurationManager = configurationManager; _appHost = appHost; + + _jsonOptions = new JsonSerializerOptions(JsonDefaults.GetOptions()); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStringConverter()); + _jsonOptions.Converters.Add(new JsonOmdbNotAvailableStructConverter<int>()); } public async Task Fetch<T>(MetadataResult<T> itemResult, string imdbId, string language, string country, CancellationToken cancellationToken) @@ -220,7 +226,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } - var result = _jsonSerializer.DeserializeFromString<RootObject>(resultString); + var result = JsonSerializer.Deserialize<RootObject>(resultString, _jsonOptions); return result; } @@ -239,7 +245,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } - var result = _jsonSerializer.DeserializeFromString<SeasonRootObject>(resultString); + var result = JsonSerializer.Deserialize<SeasonRootObject>(resultString, _jsonOptions); return result; } @@ -297,11 +303,10 @@ namespace MediaBrowser.Providers.Plugins.Omdb "i={0}&plot=short&tomatoes=true&r=json", imdbParam)); - using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<RootObject>(stream).ConfigureAwait(false); + var rootObject = await GetDeserializedOmdbResponse<RootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(rootObject, path); + await using FileStream jsonFileStream = File.OpenWrite(path); + await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); return path; } @@ -335,15 +340,22 @@ namespace MediaBrowser.Providers.Plugins.Omdb imdbParam, seasonId)); - using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<SeasonRootObject>(stream).ConfigureAwait(false); + var rootObject = await GetDeserializedOmdbResponse<SeasonRootObject>(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); Directory.CreateDirectory(Path.GetDirectoryName(path)); - _jsonSerializer.SerializeToFile(rootObject, path); + await using FileStream jsonFileStream = File.OpenWrite(path); + await JsonSerializer.SerializeAsync(jsonFileStream, rootObject, _jsonOptions, cancellationToken).ConfigureAwait(false); return path; } + public async Task<T> GetDeserializedOmdbResponse<T>(HttpClient httpClient, string url, CancellationToken cancellationToken) + { + using var response = await GetOmdbResponse(httpClient, url, cancellationToken).ConfigureAwait(false); + await using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + return await JsonSerializer.DeserializeAsync<T>(content, _jsonOptions, cancellationToken).ConfigureAwait(false); + } + public static Task<HttpResponseMessage> GetOmdbResponse(HttpClient httpClient, string url, CancellationToken cancellationToken) { return httpClient.GetAsync(url, cancellationToken); @@ -425,7 +437,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var person = new PersonInfo { - Name = result.Director.Trim(), + Name = result.Writer.Trim(), Type = PersonType.Writer }; @@ -465,7 +477,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb public string seriesID { get; set; } - public int Season { get; set; } + public int? Season { get; set; } public int? totalSeasons { get; set; } @@ -526,7 +538,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb public string Response { get; set; } - public int Episode { get; set; } + public int? Episode { get; set; } public float? GetRottenTomatoScore() { diff --git a/MediaBrowser.Providers/Properties/AssemblyInfo.cs b/MediaBrowser.Providers/Properties/AssemblyInfo.cs index f1c46899c..fe4749c79 100644 --- a/MediaBrowser.Providers/Properties/AssemblyInfo.cs +++ b/MediaBrowser.Providers/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,7 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Common.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index c8fc568a2..967908197 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -1,10 +1,16 @@ #pragma warning disable CS1591 +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging; @@ -13,14 +19,27 @@ namespace MediaBrowser.Providers.TV { public class SeriesMetadataService : MetadataService<Series, SeriesInfo> { + private readonly ILocalizationManager _localizationManager; + public SeriesMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger<SeriesMetadataService> logger, IProviderManager providerManager, IFileSystem fileSystem, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + ILocalizationManager localizationManager) : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager) { + _localizationManager = localizationManager; + } + + /// <inheritdoc /> + protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) + { + await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false); + + RemoveObsoleteSeasons(item); + await FillInMissingSeasonsAsync(item, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> @@ -62,5 +81,117 @@ namespace MediaBrowser.Providers.TV targetItem.AirDays = sourceItem.AirDays; } } + + private void RemoveObsoleteSeasons(Series series) + { + // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in FillInMissingSeasonsAsync. + var physicalSeasonNumbers = new HashSet<int>(); + var virtualSeasons = new List<Season>(); + foreach (var existingSeason in series.Children.OfType<Season>()) + { + if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue) + { + physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value); + } + else if (existingSeason.LocationType == LocationType.Virtual) + { + virtualSeasons.Add(existingSeason); + } + } + + foreach (var virtualSeason in virtualSeasons) + { + var seasonNumber = virtualSeason.IndexNumber; + // If there's a physical season with the same number or no episodes in the season, delete it + if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value)) + || !virtualSeason.GetEpisodes().Any()) + { + Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.IndexNumber, series.Name); + + LibraryManager.DeleteItem( + virtualSeason, + new DeleteOptions + { + DeleteFileLocation = true + }, + false); + } + } + } + + /// <summary> + /// Creates seasons for all episodes that aren't in a season folder. + /// If no season number can be determined, a dummy season will be created. + /// </summary> + /// <param name="series">The series.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The async task.</returns> + private async Task FillInMissingSeasonsAsync(Series series, CancellationToken cancellationToken) + { + var episodesInSeriesFolder = series.GetRecursiveChildren(i => i is Episode) + .Cast<Episode>() + .Where(i => !i.IsInSeasonFolder); + + List<Season> seasons = series.Children.OfType<Season>().ToList(); + + // Loop through the unique season numbers + foreach (var episode in episodesInSeriesFolder) + { + // Null season numbers will have a 'dummy' season created because seasons are always required. + var seasonNumber = episode.ParentIndexNumber >= 0 ? episode.ParentIndexNumber : null; + var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber); + + if (existingSeason == null) + { + var season = await CreateSeasonAsync(series, seasonNumber, cancellationToken).ConfigureAwait(false); + seasons.Add(season); + } + else if (existingSeason.IsVirtualItem) + { + existingSeason.IsVirtualItem = false; + await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + } + + /// <summary> + /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata. + /// </summary> + /// <param name="series">The series.</param> + /// <param name="seasonNumber">The season number.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The newly created season.</returns> + private async Task<Season> CreateSeasonAsync( + Series series, + int? seasonNumber, + CancellationToken cancellationToken) + { + string seasonName = seasonNumber switch + { + null => _localizationManager.GetLocalizedString("NameSeasonUnknown"), + 0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName, + _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value) + }; + + Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name); + + var season = new Season + { + Name = seasonName, + IndexNumber = seasonNumber, + Id = LibraryManager.GetNewItemId( + series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName, + typeof(Season)), + IsVirtualItem = false, + SeriesId = series.Id, + SeriesName = series.Name + }; + + series.AddChild(season, cancellationToken); + + await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false); + + return season; + } } } @@ -105,12 +105,6 @@ There are three options to get the files for the web client. 2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web) 3. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web` -Once you have a copy of the built web client files, you need to copy them into a specific directory. - -> `<repository root>/Mediabrowser.WebDashboard/jellyfin-web` - -As part of the build process, this folder will be copied to the build output directory, where it can be accessed by the server. - ### Running The Server The following instructions will help you get the project up and running via the command line, or your preferred IDE. @@ -133,7 +127,7 @@ To run the server from the command line you can use the `dotnet run` command. Th ```bash cd jellyfin # Move into the repository directory -dotnet run --project Jellyfin.Server # Run the server startup project +dotnet run --project Jellyfin.Server --webdir /absolute/path/to/jellyfin-web/dist # Run the server startup project ``` A second option is to build the project and then run the resulting executable file directly. When running the executable directly you can easily add command line options. Add the `--help` flag to list details on all the supported command line options. diff --git a/deployment/Dockerfile.debian.amd64 b/deployment/Dockerfile.debian.amd64 index f0d9188c1..d2f98ca82 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.debian.arm64 b/deployment/Dockerfile.debian.arm64 index 8132ee887..ffc94e088 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.debian.armhf b/deployment/Dockerfile.debian.armhf index 31f534838..b25f59329 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.linux.amd64 b/deployment/Dockerfile.linux.amd64 index 2bedafcc5..2e993c25d 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.linux.amd64-musl b/deployment/Dockerfile.linux.amd64-musl new file mode 100644 index 000000000..08b4ffa52 --- /dev/null +++ b/deployment/Dockerfile.linux.amd64-musl @@ -0,0 +1,31 @@ +FROM debian:10 +# Docker build arguments +ARG SOURCE_DIR=/jellyfin +ARG ARTIFACT_DIR=/dist +ARG SDK_VERSION=5.0 +# Docker run environment +ENV SOURCE_DIR=/jellyfin +ENV ARTIFACT_DIR=/dist +ENV DEB_BUILD_OPTIONS=noddebs +ENV ARCH=amd64 +ENV IS_DOCKER=YES + +# Prepare Debian build environment +RUN apt-get update \ + && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + +# Install dotnet repository +# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet + +# Link to docker-build script +RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.amd64-musl /build.sh + +VOLUME ${SOURCE_DIR}/ + +VOLUME ${ARTIFACT_DIR}/ + +ENTRYPOINT ["/build.sh"] diff --git a/deployment/Dockerfile.linux.arm64 b/deployment/Dockerfile.linux.arm64 new file mode 100644 index 000000000..b8499c917 --- /dev/null +++ b/deployment/Dockerfile.linux.arm64 @@ -0,0 +1,31 @@ +FROM debian:10 +# Docker build arguments +ARG SOURCE_DIR=/jellyfin +ARG ARTIFACT_DIR=/dist +ARG SDK_VERSION=5.0 +# Docker run environment +ENV SOURCE_DIR=/jellyfin +ENV ARTIFACT_DIR=/dist +ENV DEB_BUILD_OPTIONS=noddebs +ENV ARCH=arm64 +ENV IS_DOCKER=YES + +# Prepare Debian build environment +RUN apt-get update \ + && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + +# Install dotnet repository +# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet + +# Link to docker-build script +RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.arm64 /build.sh + +VOLUME ${SOURCE_DIR}/ + +VOLUME ${ARTIFACT_DIR}/ + +ENTRYPOINT ["/build.sh"] diff --git a/deployment/Dockerfile.linux.armhf b/deployment/Dockerfile.linux.armhf new file mode 100644 index 000000000..80c4d1469 --- /dev/null +++ b/deployment/Dockerfile.linux.armhf @@ -0,0 +1,31 @@ +FROM debian:10 +# Docker build arguments +ARG SOURCE_DIR=/jellyfin +ARG ARTIFACT_DIR=/dist +ARG SDK_VERSION=5.0 +# Docker run environment +ENV SOURCE_DIR=/jellyfin +ENV ARTIFACT_DIR=/dist +ENV DEB_BUILD_OPTIONS=noddebs +ENV ARCH=armhf +ENV IS_DOCKER=YES + +# Prepare Debian build environment +RUN apt-get update \ + && apt-get install -y apt-transport-https debhelper gnupg wget devscripts mmv libc6-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 + +# Install dotnet repository +# https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ + && mkdir -p dotnet-sdk \ + && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ + && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet + +# Link to docker-build script +RUN ln -sf ${SOURCE_DIR}/deployment/build.linux.armhf /build.sh + +VOLUME ${SOURCE_DIR}/ + +VOLUME ${ARTIFACT_DIR}/ + +ENTRYPOINT ["/build.sh"] diff --git a/deployment/Dockerfile.macos b/deployment/Dockerfile.macos index d470f9b74..f2bbe7f24 100644 --- a/deployment/Dockerfile.macos +++ b/deployment/Dockerfile.macos @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable index d2007c075..603becedf 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -15,7 +15,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 084159d45..a6c7cc5d4 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index c2caf4cf8..3a8005816 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index 719b3a85b..22b9e7ea8 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.windows.amd64 b/deployment/Dockerfile.windows.amd64 index e6905c906..b1ca61053 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -15,7 +15,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/a0487784-534a-4912-a4dd-017382083865/be16057043a8f7b6f08c902dc48dd677/dotnet-sdk-5.0.101-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/build.linux.amd64-musl b/deployment/build.linux.amd64-musl new file mode 100755 index 000000000..72444c05e --- /dev/null +++ b/deployment/build.linux.amd64-musl @@ -0,0 +1,31 @@ +#!/bin/bash + +#= Generic Linux amd64-musl .tar.gz + +set -o errexit +set -o xtrace + +# Move to source directory +pushd ${SOURCE_DIR} + +# Get version +if [[ ${IS_UNSTABLE} == 'yes' ]]; then + version="${BUILD_ID}" +else + version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )" +fi + +# Build archives +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-musl-x64 --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +tar -czf jellyfin-server_${version}_linux-amd64-musl.tar.gz -C dist jellyfin-server_${version} +rm -rf dist/jellyfin-server_${version} + +# Move the artifacts out +mkdir -p ${ARTIFACT_DIR}/ +mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ + +if [[ ${IS_DOCKER} == YES ]]; then + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} +fi + +popd diff --git a/deployment/build.linux.arm64 b/deployment/build.linux.arm64 new file mode 100755 index 000000000..e362607a7 --- /dev/null +++ b/deployment/build.linux.arm64 @@ -0,0 +1,31 @@ +#!/bin/bash + +#= Generic Linux arm64 .tar.gz + +set -o errexit +set -o xtrace + +# Move to source directory +pushd ${SOURCE_DIR} + +# Get version +if [[ ${IS_UNSTABLE} == 'yes' ]]; then + version="${BUILD_ID}" +else + version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )" +fi + +# Build archives +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm64 --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +tar -czf jellyfin-server_${version}_linux-arm64.tar.gz -C dist jellyfin-server_${version} +rm -rf dist/jellyfin-server_${version} + +# Move the artifacts out +mkdir -p ${ARTIFACT_DIR}/ +mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ + +if [[ ${IS_DOCKER} == YES ]]; then + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} +fi + +popd diff --git a/deployment/build.linux.armhf b/deployment/build.linux.armhf new file mode 100755 index 000000000..c0d0607fc --- /dev/null +++ b/deployment/build.linux.armhf @@ -0,0 +1,31 @@ +#!/bin/bash + +#= Generic Linux armhf .tar.gz + +set -o errexit +set -o xtrace + +# Move to source directory +pushd ${SOURCE_DIR} + +# Get version +if [[ ${IS_UNSTABLE} == 'yes' ]]; then + version="${BUILD_ID}" +else + version="$( grep "version:" ./build.yaml | sed -E 's/version: "([0-9\.]+.*)"/\1/' )" +fi + +# Build archives +dotnet publish Jellyfin.Server --configuration Release --self-contained --runtime linux-arm --output dist/jellyfin-server_${version}/ "-p:DebugSymbols=false;DebugType=none;UseAppHost=true" +tar -czf jellyfin-server_${version}_linux-armhf.tar.gz -C dist jellyfin-server_${version} +rm -rf dist/jellyfin-server_${version} + +# Move the artifacts out +mkdir -p ${ARTIFACT_DIR}/ +mv jellyfin[-_]*.tar.gz ${ARTIFACT_DIR}/ + +if [[ ${IS_DOCKER} == YES ]]; then + chown -Rc $(stat -c %u:%g ${ARTIFACT_DIR}) ${ARTIFACT_DIR} +fi + +popd diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 7c552ec06..b5e8e521c 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -16,9 +16,9 @@ <PackageReference Include="AutoFixture" Version="4.14.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.14.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="5.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="5.0.1" /> <PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index e8eca6760..19c5612c0 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -13,7 +13,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> @@ -29,6 +29,7 @@ <ItemGroup> <ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" /> + <ProjectReference Include="../../MediaBrowser.Providers/MediaBrowser.Providers.csproj" /> </ItemGroup> <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> diff --git a/tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs new file mode 100644 index 000000000..9ded01f2b --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Json/JsonBoolNumberTests.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using MediaBrowser.Common.Json.Converters; +using Xunit; + +namespace Jellyfin.Common.Tests.Json +{ + public static class JsonBoolNumberTests + { + [Theory] + [InlineData("1", true)] + [InlineData("0", false)] + [InlineData("2", true)] + [InlineData("true", true)] + [InlineData("false", false)] + public static void Deserialize_Number_Valid_Success(string input, bool? output) + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonBoolNumberConverter()); + var value = JsonSerializer.Deserialize<bool>(input, options); + Assert.Equal(value, output); + } + + [Theory] + [InlineData(true, "true")] + [InlineData(false, "false")] + public static void Serialize_Bool_Success(bool input, string output) + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonBoolNumberConverter()); + var value = JsonSerializer.Serialize(input, options); + Assert.Equal(value, output); + } + } +}
\ No newline at end of file diff --git a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs index 3c94db491..1e1cde957 100644 --- a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs +++ b/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs @@ -1,9 +1,10 @@ using System; +using System.Globalization; using System.Text.Json; using MediaBrowser.Common.Json.Converters; using Xunit; -namespace Jellyfin.Common.Tests.Extensions +namespace Jellyfin.Common.Tests.Json { public class JsonGuidConverterTests { @@ -44,9 +45,25 @@ namespace Jellyfin.Common.Tests.Extensions } [Fact] - public void Serialize_EmptyGuid_Null() + public void Serialize_EmptyGuid_EmptyGuid() { - Assert.Equal("null", JsonSerializer.Serialize(Guid.Empty, _options)); + Assert.Equal($"\"{Guid.Empty:N}\"", JsonSerializer.Serialize(Guid.Empty, _options)); + } + + [Fact] + public void Serialize_Valid_NoDash_Success() + { + var guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); + } + + [Fact] + public void Serialize_Nullable_Success() + { + Guid? guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); } } } diff --git a/tests/Jellyfin.Common.Tests/Json/JsonNullableGuidConverterTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonNullableGuidConverterTests.cs new file mode 100644 index 000000000..efc0c4af9 --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Json/JsonNullableGuidConverterTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Globalization; +using System.Text.Json; +using MediaBrowser.Common.Json.Converters; +using Xunit; + +namespace Jellyfin.Common.Tests.Json +{ + public class JsonNullableGuidConverterTests + { + private readonly JsonSerializerOptions _options; + + public JsonNullableGuidConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new JsonNullableGuidConverter()); + } + + [Fact] + public void Deserialize_Valid_Success() + { + Guid? value = JsonSerializer.Deserialize<Guid?>(@"""a852a27afe324084ae66db579ee3ee18""", _options); + Assert.Equal(new Guid("a852a27afe324084ae66db579ee3ee18"), value); + } + + [Fact] + public void Deserialize_ValidDashed_Success() + { + Guid? value = JsonSerializer.Deserialize<Guid?>(@"""e9b2dcaa-529c-426e-9433-5e9981f27f2e""", _options); + Assert.Equal(new Guid("e9b2dcaa-529c-426e-9433-5e9981f27f2e"), value); + } + + [Fact] + public void Roundtrip_Valid_Success() + { + Guid guid = new Guid("a852a27afe324084ae66db579ee3ee18"); + string value = JsonSerializer.Serialize(guid, _options); + Assert.Equal(guid, JsonSerializer.Deserialize<Guid?>(value, _options)); + } + + [Fact] + public void Deserialize_Null_EmptyGuid() + { + Assert.Null(JsonSerializer.Deserialize<Guid?>("null", _options)); + } + + [Fact] + public void Serialize_EmptyGuid_EmptyGuid() + { + Assert.Equal("null", JsonSerializer.Serialize((Guid?)Guid.Empty, _options)); + } + + [Fact] + public void Serialize_Valid_NoDash_Success() + { + var guid = (Guid?)new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); + } + + [Fact] + public void Serialize_Nullable_Success() + { + Guid? guid = new Guid("531797E9-9457-40E0-88BC-B1D6D38752FA"); + var str = JsonSerializer.Serialize(guid, _options); + Assert.Equal($"\"{guid:N}\"", str); + } + } +} diff --git a/tests/Jellyfin.Common.Tests/Json/JsonOmdbConverterTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonOmdbConverterTests.cs new file mode 100644 index 000000000..6f85fe092 --- /dev/null +++ b/tests/Jellyfin.Common.Tests/Json/JsonOmdbConverterTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; +using MediaBrowser.Providers.Plugins.Omdb; +using Xunit; + +namespace Jellyfin.Common.Tests.Json +{ + public class JsonOmdbConverterTests + { + private readonly JsonSerializerOptions _options; + + public JsonOmdbConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new JsonOmdbNotAvailableStringConverter()); + _options.Converters.Add(new JsonOmdbNotAvailableStructConverter<int>()); + _options.NumberHandling = JsonNumberHandling.AllowReadingFromString; + } + + [Fact] + public void Deserialize_Omdb_Response_Not_Available_Success() + { + const string Input = "{\"Title\":\"Chapter 1\",\"Year\":\"2013\",\"Rated\":\"TV-MA\",\"Released\":\"01 Feb 2013\",\"Season\":\"N/A\",\"Episode\":\"N/A\",\"Runtime\":\"55 min\",\"Genre\":\"Drama\",\"Director\":\"David Fincher\",\"Writer\":\"Michael Dobbs (based on the novels by), Andrew Davies (based on the mini-series by), Beau Willimon (created for television by), Beau Willimon, Sam Forman (staff writer)\",\"Actors\":\"Kevin Spacey, Robin Wright, Kate Mara, Corey Stoll\",\"Plot\":\"Congressman Francis Underwood has been declined the chair for Secretary of State. He's now gathering his own team to plot his revenge. Zoe Barnes, a reporter for the Washington Herald, will do anything to get her big break.\",\"Language\":\"English\",\"Country\":\"USA\",\"Awards\":\"N/A\",\"Poster\":\"https://m.media-amazon.com/images/M/MV5BMTY5MTU4NDQzNV5BMl5BanBnXkFtZTgwMzk2ODcxMzE@._V1_SX300.jpg\",\"Ratings\":[{\"Source\":\"Internet Movie Database\",\"Value\":\"8.7/10\"}],\"Metascore\":\"N/A\",\"imdbRating\":\"8.7\",\"imdbVotes\":\"6736\",\"imdbID\":\"tt2161930\",\"seriesID\":\"N/A\",\"Type\":\"episode\",\"Response\":\"True\"}"; + var seasonRootObject = JsonSerializer.Deserialize<OmdbProvider.RootObject>(Input, _options); + Assert.NotNull(seasonRootObject); + Assert.Null(seasonRootObject?.Awards); + Assert.Null(seasonRootObject?.Episode); + Assert.Null(seasonRootObject?.Metascore); + } + + [Theory] + [InlineData("\"N/A\"")] + [InlineData("null")] + public void Deserialization_To_Nullable_Int_Shoud_Be_Null(string input) + { + var result = JsonSerializer.Deserialize<int?>(input, _options); + Assert.Null(result); + } + + [Theory] + [InlineData("\"N/A\"")] + [InlineData("null")] + public void Deserialization_To_Nullable_String_Shoud_Be_Null(string input) + { + var result = JsonSerializer.Deserialize<string?>(input, _options); + Assert.Null(result); + } + + [Theory] + [InlineData("\"8\"", 8)] + [InlineData("8", 8)] + public void Deserialize_Int_Success(string input, int expected) + { + var result = JsonSerializer.Deserialize<int>(input, _options); + Assert.Equal(result, expected); + } + + [Fact] + public void Deserialize_Normal_String_Success() + { + const string Input = "\"Jellyfin\""; + const string Expected = "Jellyfin"; + var result = JsonSerializer.Deserialize<string>(Input, _options); + Assert.Equal(Expected, result); + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 6e3fac43d..1ec88dada 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -13,7 +13,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj index f91db6744..8c9dc4820 100644 --- a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj +++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj @@ -8,7 +8,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index e88de3811..c934ea1c2 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -19,7 +19,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 567cf34ef..6118581e1 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -13,7 +13,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj index 48b0b4c7d..90782f6bb 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj @@ -13,7 +13,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index fffbc6212..bcd12deaf 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -16,7 +16,7 @@ <ItemGroup> <PackageReference Include="AutoFixture" Version="4.14.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="Moq" Version="4.15.2" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> @@ -35,6 +35,11 @@ <ProjectReference Include="..\..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" /> </ItemGroup> + <ItemGroup> + <EmbeddedResource Include="LiveTv\discover.json" /> + <EmbeddedResource Include="LiveTv\lineup.json" /> + </ItemGroup> + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet> </PropertyGroup> diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs new file mode 100644 index 000000000..fb7cf6a47 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; +using MediaBrowser.Model.LiveTv; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.LiveTv +{ + public class HdHomerunHostTests + { + private const string TestIp = "http://192.168.1.182"; + + private readonly Fixture _fixture; + private readonly HdHomerunHost _hdHomerunHost; + + public HdHomerunHostTests() + { + const string BaseResourcePath = "Jellyfin.Server.Implementations.Tests.LiveTv."; + + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>( + (m, _) => + { + var resource = BaseResourcePath + m.RequestUri?.Segments[^1]; + var stream = typeof(HdHomerunHostTests).Assembly.GetManifestResourceStream(resource); + if (stream == null) + { + throw new NullReferenceException("Resource doesn't exist: " + resource); + } + + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(stream) + }); + }); + + var http = new Mock<IHttpClientFactory>(); + http.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _hdHomerunHost = _fixture.Create<HdHomerunHost>(); + } + + [Fact] + public async Task GetModelInfo_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = TestIp + }; + + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None).ConfigureAwait(false); + Assert.Equal("HDHomeRun PRIME", modelInfo.FriendlyName); + Assert.Equal("HDHR3-CC", modelInfo.ModelNumber); + Assert.Equal("hdhomerun3_cablecard", modelInfo.FirmwareName); + Assert.Equal("20160630atest2", modelInfo.FirmwareVersion); + Assert.Equal("FFFFFFFF", modelInfo.DeviceID); + Assert.Equal("FFFFFFFF", modelInfo.DeviceAuth); + Assert.Equal(3, modelInfo.TunerCount); + Assert.Equal("http://192.168.1.182:80", modelInfo.BaseURL); + Assert.Equal("http://192.168.1.182:80/lineup.json", modelInfo.LineupURL); + } + + [Fact] + public async Task GetModelInfo_EmptyUrl_ArgumentException() + { + var host = new TunerHostInfo() + { + Url = string.Empty + }; + + await Assert.ThrowsAsync<ArgumentException>(() => _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None)); + } + + [Fact] + public async Task GetLineup_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = TestIp + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None).ConfigureAwait(false); + Assert.Equal(6, channels.Count); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task GetLineup_ImportFavoritesOnly_Success() + { + var host = new TunerHostInfo() + { + Url = TestIp, + ImportFavoritesOnly = true + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None).ConfigureAwait(false); + Assert.Single(channels); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task TryGetTunerHostInfo_Valid_Success() + { + var host = await _hdHomerunHost.TryGetTunerHostInfo(TestIp, CancellationToken.None).ConfigureAwait(false); + Assert.Equal(_hdHomerunHost.Type, host.Type); + Assert.Equal(TestIp, host.Url); + Assert.Equal("HDHomeRun PRIME", host.FriendlyName); + Assert.Equal("FFFFFFFF", host.DeviceId); + Assert.Equal(3, host.TunerCount); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/discover.json b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/discover.json new file mode 100644 index 000000000..851f17bb2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/discover.json @@ -0,0 +1 @@ +{"FriendlyName":"HDHomeRun PRIME","ModelNumber":"HDHR3-CC","FirmwareName":"hdhomerun3_cablecard","FirmwareVersion":"20160630atest2","DeviceID":"FFFFFFFF","DeviceAuth":"FFFFFFFF","TunerCount":3,"ConditionalAccess":1,"BaseURL":"http://192.168.1.182:80","LineupURL":"http://192.168.1.182:80/lineup.json"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/lineup.json b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/lineup.json new file mode 100644 index 000000000..4cb5ebc8e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/lineup.json @@ -0,0 +1 @@ +[ { "GuideNumber": "4.1", "GuideName": "WCMH-DT", "HD": 1, "Favorite": 1, "URL": "http://192.168.1.111:5004/auto/v4.1" }, { "GuideNumber": "4.2", "GuideName": "MeTV", "URL": "http://192.168.1.111:5004/auto/v4.2" }, { "GuideNumber": "4.3", "GuideName": "ION TV", "URL": "http://192.168.1.111:5004/auto/v4.3" }, { "GuideNumber": "6.1", "GuideName": "WSYX DT", "HD": 1, "URL": "http://192.168.1.111:5004/auto/v6.1" }, { "GuideNumber": "6.2", "GuideName": "MYTV", "URL": "http://192.168.1.111:5004/auto/v6.2" }, { "GuideNumber": "6.3", "GuideName": "ANTENNA", "URL": "http://192.168.1.111:5004/auto/v6.3" } ] |
