diff options
| author | Joshua M. Boniface <joshua@boniface.me> | 2021-08-18 02:46:59 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-08-18 02:46:59 -0400 |
| commit | 72d3f7020ad80ce1a53eeae8c5d57abeb22a4679 (patch) | |
| tree | dd43e663838cdc7d99a4af565523df58ae23c856 /Emby.Server.Implementations | |
| parent | 7aef0fce444e6d8e06386553ec7ea1401a01bbb1 (diff) | |
| parent | e5cbafdb6b47377052e0d638908ef96e30a997d6 (diff) | |
Merge branch 'master' into patch-2
Diffstat (limited to 'Emby.Server.Implementations')
253 files changed, 8551 insertions, 11702 deletions
diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 2adc1d6c3..6edfad575 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -33,7 +33,7 @@ namespace Emby.Server.Implementations.AppBase CachePath = cacheDirectoryPath; WebPath = webDirectoryPath; - DataPath = Path.Combine(ProgramDataPath, "data"); + _dataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } /// <summary> @@ -55,14 +55,10 @@ namespace Emby.Server.Implementations.AppBase /// Gets the folder path to the data directory. /// </summary> /// <value>The data directory.</value> - public string DataPath - { - get => _dataPath; - private set => _dataPath = Directory.CreateDirectory(value).FullName; - } + public string DataPath => _dataPath; /// <inheritdoc /> - public string VirtualDataPath { get; } = "%AppDataPath%"; + public string VirtualDataPath => "%AppDataPath%"; /// <summary> /// Gets the image cache path. diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index d4a8268b9..d38535634 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -23,6 +25,11 @@ namespace Emby.Server.Implementations.AppBase private readonly ConcurrentDictionary<string, object> _configurations = new ConcurrentDictionary<string, object>(); + /// <summary> + /// The _configuration sync lock. + /// </summary> + private readonly object _configurationSyncLock = new object(); + private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>(); private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>(); @@ -32,11 +39,6 @@ namespace Emby.Server.Implementations.AppBase private bool _configurationLoaded; /// <summary> - /// The _configuration sync lock. - /// </summary> - private readonly object _configurationSyncLock = new object(); - - /// <summary> /// The _configuration. /// </summary> private BaseApplicationConfiguration _configuration; @@ -134,6 +136,33 @@ namespace Emby.Server.Implementations.AppBase } /// <summary> + /// Manually pre-loads a factory so that it is available pre system initialisation. + /// </summary> + /// <typeparam name="T">Class to register.</typeparam> + public virtual void RegisterConfiguration<T>() + where T : IConfigurationFactory + { + IConfigurationFactory factory = Activator.CreateInstance<T>(); + + if (_configurationFactories == null) + { + _configurationFactories = new[] { factory }; + } + else + { + var oldLen = _configurationFactories.Length; + var arr = new IConfigurationFactory[oldLen + 1]; + _configurationFactories.CopyTo(arr, 0); + arr[oldLen] = factory; + _configurationFactories = arr; + } + + _configurationStores = _configurationFactories + .SelectMany(i => i.GetConfigurations()) + .ToArray(); + } + + /// <summary> /// Adds parts. /// </summary> /// <param name="factories">The configuration factories.</param> @@ -270,25 +299,29 @@ namespace Emby.Server.Implementations.AppBase /// <inheritdoc /> public object GetConfiguration(string key) { - return _configurations.GetOrAdd(key, k => - { - var file = GetConfigurationFile(key); + return _configurations.GetOrAdd( + key, + (k, configurationManager) => + { + var file = configurationManager.GetConfigurationFile(k); - var configurationInfo = _configurationStores - .FirstOrDefault(i => string.Equals(i.Key, key, StringComparison.OrdinalIgnoreCase)); + var configurationInfo = Array.Find( + configurationManager._configurationStores, + i => string.Equals(i.Key, k, StringComparison.OrdinalIgnoreCase)); - if (configurationInfo == null) - { - throw new ResourceNotFoundException("Configuration with key " + key + " not found."); - } + if (configurationInfo == null) + { + throw new ResourceNotFoundException("Configuration with key " + k + " not found."); + } - var configurationType = configurationInfo.ConfigurationType; + var configurationType = configurationInfo.ConfigurationType; - lock (_configurationSyncLock) - { - return LoadConfiguration(file, configurationType); - } - }); + lock (configurationManager._configurationSyncLock) + { + return configurationManager.LoadConfiguration(file, configurationType); + } + }, + this); } private object LoadConfiguration(string path, Type configurationType) @@ -308,7 +341,7 @@ namespace Emby.Server.Implementations.AppBase } catch (Exception ex) { - Logger.LogError(ex, "Error loading configuration file: {path}", path); + Logger.LogError(ex, "Error loading configuration file: {Path}", path); return Activator.CreateInstance(configurationType); } diff --git a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs index 4c9ab33a7..0308a68e4 100644 --- a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs +++ b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.IO; using System.Linq; @@ -35,7 +33,8 @@ namespace Emby.Server.Implementations.AppBase } catch (Exception) { - configuration = Activator.CreateInstance(type); + // Note: CreateInstance returns null for Nullable<T>, e.g. CreateInstance(typeof(int?)) returns null. + configuration = Activator.CreateInstance(type)!; } using var stream = new MemoryStream(buffer?.Length ?? 0); @@ -48,10 +47,12 @@ namespace Emby.Server.Implementations.AppBase // If the file didn't exist before, or if something has changed, re-save if (buffer == null || !newBytes.AsSpan(0, newBytesLen).SequenceEqual(buffer)) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); + var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); + Directory.CreateDirectory(directory); // Save it after load in case we got new items - using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) { fs.Write(newBytes, 0, newBytesLen); } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index cb63cc85e..bf7ddace2 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -1,18 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 using System; -using System.Collections.Concurrent; 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.Net.Sockets; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; -using System.Text; using System.Threading; using System.Threading.Tasks; using Emby.Dlna; @@ -29,7 +28,6 @@ using Emby.Server.Implementations.Cryptography; using Emby.Server.Implementations.Data; using Emby.Server.Implementations.Devices; using Emby.Server.Implementations.Dto; -using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; @@ -37,15 +35,19 @@ using Emby.Server.Implementations.LiveTv; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Net; using Emby.Server.Implementations.Playlists; +using Emby.Server.Implementations.Plugins; +using Emby.Server.Implementations.QuickConnect; using Emby.Server.Implementations.ScheduledTasks; using Emby.Server.Implementations.Security; using Emby.Server.Implementations.Serialization; -using Emby.Server.Implementations.Services; using Emby.Server.Implementations.Session; using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; +using Emby.Server.Implementations.Udp; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Manager; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; @@ -71,6 +73,7 @@ using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; @@ -80,7 +83,6 @@ using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.MediaEncoding.BdInfo; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; @@ -88,19 +90,20 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Manager; -using MediaBrowser.Providers.Plugins.TheTvdb; +using MediaBrowser.Providers.Plugins.Tmdb; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Prometheus.DotNetRuntime; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; +using WebSocketManager = Emby.Server.Implementations.HttpServer.WebSocketManager; namespace Emby.Server.Implementations { @@ -115,20 +118,23 @@ namespace Emby.Server.Implementations private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; private readonly IFileSystem _fileSystemManager; - private readonly INetworkManager _networkManager; + private readonly IConfiguration _startupConfig; private readonly IXmlSerializer _xmlSerializer; private readonly IStartupOptions _startupOptions; + private readonly IPluginManager _pluginManager; + private List<Type> _creatingInstances; private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; - private IHttpServer _httpServer; - private IHttpClient _httpClient; + private string[] _urlPrefixes; /// <summary> /// Gets a value indicating whether this instance can self restart. /// </summary> public bool CanSelfRestart => _startupOptions.RestartPath != null; + public bool CoreStartupHasCompleted { get; private set; } + public virtual bool CanLaunchWebBrowser { get @@ -143,17 +149,16 @@ namespace Emby.Server.Implementations return false; } - if (OperatingSystem.Id == OperatingSystemId.Windows - || OperatingSystem.Id == OperatingSystemId.Darwin) - { - return true; - } - - return false; + return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); } } /// <summary> + /// Gets the <see cref="INetworkManager"/> singleton instance. + /// </summary> + public INetworkManager NetManager { get; internal set; } + + /// <summary> /// Occurs when [has pending restart changed]. /// </summary> public event EventHandler HasPendingRestartChanged; @@ -174,14 +179,6 @@ namespace Emby.Server.Implementations protected IServiceCollection ServiceCollection { get; } - private IPlugin[] _plugins; - - /// <summary> - /// Gets the plugins. - /// </summary> - /// <value>The plugins.</value> - public IReadOnlyList<IPlugin> Plugins => _plugins; - /// <summary> /// Gets the logger factory. /// </summary> @@ -205,10 +202,10 @@ namespace Emby.Server.Implementations private readonly List<IDisposable> _disposableParts = new List<IDisposable>(); /// <summary> - /// Gets the configuration manager. + /// Gets or sets the configuration manager. /// </summary> /// <value>The configuration manager.</value> - protected IConfigurationManager ConfigurationManager { get; set; } + public ServerConfigurationManager ConfigurationManager { get; set; } /// <summary> /// Gets or sets the service provider. @@ -226,54 +223,65 @@ namespace Emby.Server.Implementations public int HttpsPort { get; private set; } /// <summary> - /// Gets the server configuration manager. + /// Gets the value of the PublishedServerUrl setting. /// </summary> - /// <value>The server configuration manager.</value> - public IServerConfigurationManager ServerConfigurationManager => (IServerConfigurationManager)ConfigurationManager; + public string PublishedServerUrl => _startupOptions.PublishedServerUrl ?? _startupConfig[UdpServer.AddressOverrideConfigKey]; /// <summary> - /// Initializes a new instance of the <see cref="ApplicationHost" /> class. + /// Initializes a new instance of the <see cref="ApplicationHost"/> class. /// </summary> + /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param> + /// <param name="startupConfig">The <see cref="IConfiguration" /> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> public ApplicationHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, + IConfiguration startupConfig, IFileSystem fileSystem, - INetworkManager networkManager, IServiceCollection serviceCollection) { - _xmlSerializer = new MyXmlSerializer(); - ServiceCollection = serviceCollection; - - _networkManager = networkManager; - networkManager.LocalSubnetsFn = GetConfiguredLocalSubnets; - ApplicationPaths = applicationPaths; LoggerFactory = loggerFactory; + _startupOptions = options; + _startupConfig = startupConfig; _fileSystemManager = fileSystem; - - ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); + ServiceCollection = serviceCollection; Logger = LoggerFactory.CreateLogger<ApplicationHost>(); - - _startupOptions = options; - - // Initialize runtime stat collection - if (ServerConfigurationManager.Configuration.EnableMetrics) - { - DotNetRuntimeStatsBuilder.Default().StartCollecting(); - } - fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); - _networkManager.NetworkChanged += OnNetworkChanged; + ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; + ApplicationVersionString = ApplicationVersion.ToString(3); + ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; - CertificateInfo = new CertificateInfo + _xmlSerializer = new MyXmlSerializer(); + ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); + _pluginManager = new PluginManager( + LoggerFactory.CreateLogger<PluginManager>(), + this, + ConfigurationManager.Configuration, + ApplicationPaths.PluginsPath, + ApplicationVersion); + } + + /// <summary> + /// Temporary function to migration network settings out of system.xml and into network.xml. + /// TODO: remove at the point when a fixed migration path has been decided upon. + /// </summary> + private void MigrateNetworkConfiguration() + { + string path = Path.Combine(ConfigurationManager.CommonApplicationPaths.ConfigurationDirectoryPath, "network.xml"); + if (!File.Exists(path)) { - Path = ServerConfigurationManager.Configuration.CertificatePath, - Password = ServerConfigurationManager.Configuration.CertificatePassword - }; - Certificate = GetCertificate(CertificateInfo); + var networkSettings = new NetworkConfiguration(); + ClassMigrationHelper.CopyProperties(ConfigurationManager.Configuration, networkSettings); + _xmlSerializer.SerializeToFile(networkSettings, path); + Logger.LogDebug("Successfully migrated network settings."); + } } public string ExpandVirtualPath(string path) @@ -292,33 +300,23 @@ namespace Emby.Server.Implementations .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase); } - private string[] GetConfiguredLocalSubnets() - { - return ServerConfigurationManager.Configuration.LocalNetworkSubnets; - } - - private void OnNetworkChanged(object sender, EventArgs e) - { - _validAddressResults.Clear(); - } - /// <inheritdoc /> - public Version ApplicationVersion { get; } = typeof(ApplicationHost).Assembly.GetName().Version; + public Version ApplicationVersion { get; } /// <inheritdoc /> - public string ApplicationVersionString { get; } = typeof(ApplicationHost).Assembly.GetName().Version.ToString(3); + public string ApplicationVersionString { get; } /// <summary> /// Gets the current application user agent. /// </summary> /// <value>The application user agent.</value> - public string ApplicationUserAgent => Name.Replace(' ', '-') + "/" + ApplicationVersionString; + public string ApplicationUserAgent { get; } /// <summary> /// Gets the email address for use within a comment section of a user agent field. /// Presently used to provide contact information to MusicBrainz service. /// </summary> - public string ApplicationUserAgentAddress { get; } = "team@jellyfin.org"; + public string ApplicationUserAgentAddress => "team@jellyfin.org"; /// <summary> /// Gets the current application name. @@ -332,10 +330,7 @@ namespace Emby.Server.Implementations { get { - if (_deviceId == null) - { - _deviceId = new DeviceId(ApplicationPaths, LoggerFactory); - } + _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory); return _deviceId.Value; } @@ -355,7 +350,7 @@ namespace Emby.Server.Implementations /// <summary> /// Creates an instance of type and resolves all constructor dependencies. /// </summary> - /// /// <typeparam name="T">The type.</typeparam> + /// <typeparam name="T">The type.</typeparam> /// <returns>T.</returns> public T CreateInstance<T>() => ActivatorUtilities.CreateInstance<T>(ServiceProvider); @@ -367,30 +362,48 @@ namespace Emby.Server.Implementations /// <returns>System.Object.</returns> protected object CreateInstanceSafe(Type type) { + _creatingInstances ??= new List<Type>(); + + if (_creatingInstances.IndexOf(type) != -1) + { + Logger.LogError("DI Loop detected in the attempted creation of {Type}", type.FullName); + foreach (var entry in _creatingInstances) + { + Logger.LogError("Called from: {TypeName}", entry.FullName); + } + + _pluginManager.FailPlugin(type.Assembly); + + throw new ExternalException("DI Loop detected."); + } + try { + _creatingInstances.Add(type); Logger.LogDebug("Creating instance of {Type}", type); return ActivatorUtilities.CreateInstance(ServiceProvider, type); } catch (Exception ex) { Logger.LogError(ex, "Error creating {Type}", type); + // If this is a plugin fail it. + _pluginManager.FailPlugin(type.Assembly); return null; } + finally + { + _creatingInstances.Remove(type); + } } /// <summary> /// Resolves this instance. /// </summary> - /// <typeparam name="T">The type</typeparam> + /// <typeparam name="T">The type.</typeparam> /// <returns>``0.</returns> public T Resolve<T>() => ServiceProvider.GetService<T>(); - /// <summary> - /// Gets the export types. - /// </summary> - /// <typeparam name="T">The type.</typeparam> - /// <returns>IEnumerable{Type}.</returns> + /// <inheritdoc/> public IEnumerable<Type> GetExportTypes<T>() { var currentType = typeof(T); @@ -419,17 +432,40 @@ namespace Emby.Server.Implementations return parts; } + /// <inheritdoc /> + public IReadOnlyCollection<T> GetExports<T>(CreationDelegateFactory defaultFunc, bool manageLifetime = true) + { + // Convert to list so this isn't executed for each iteration + var parts = GetExportTypes<T>() + .Select(i => defaultFunc(i)) + .Where(i => i != null) + .Cast<T>() + .ToList(); + + if (manageLifetime) + { + lock (_disposableParts) + { + _disposableParts.AddRange(parts.OfType<IDisposable>()); + } + } + + return parts; + } + /// <summary> /// Runs the startup tasks. /// </summary> /// <returns><see cref="Task" />.</returns> - public async Task RunStartupTasksAsync() + public async Task RunStartupTasksAsync(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); Logger.LogInformation("Running startup tasks"); Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; + ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated; _mediaEncoder.SetFFmpegPath(); @@ -437,15 +473,21 @@ namespace Emby.Server.Implementations var entryPoints = GetExports<IServerEntryPoint>(); + cancellationToken.ThrowIfCancellationRequested(); + var stopWatch = new Stopwatch(); stopWatch.Start(); + await Task.WhenAll(StartEntryPoints(entryPoints, true)).ConfigureAwait(false); Logger.LogInformation("Executed all pre-startup entry points in {Elapsed:g}", stopWatch.Elapsed); Logger.LogInformation("Core startup complete"); - _httpServer.GlobalResponse = null; + CoreStartupHasCompleted = true; + + cancellationToken.ThrowIfCancellationRequested(); stopWatch.Restart(); + await Task.WhenAll(StartEntryPoints(entryPoints, false)).ConfigureAwait(false); Logger.LogInformation("Executed all post-startup entry points in {Elapsed:g}", stopWatch.Elapsed); stopWatch.Stop(); @@ -469,38 +511,42 @@ namespace Emby.Server.Implementations /// <inheritdoc/> public void Init() { - HttpPort = ServerConfigurationManager.Configuration.HttpServerPortNumber; - HttpsPort = ServerConfigurationManager.Configuration.HttpsPortNumber; + DiscoverTypes(); - // Safeguard against invalid configuration - if (HttpPort == HttpsPort) - { - HttpPort = ServerConfiguration.DefaultHttpPort; - HttpsPort = ServerConfiguration.DefaultHttpsPort; - } + ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); - if (Plugins != null) + // Have to migrate settings here as migration subsystem not yet initialised. + MigrateNetworkConfiguration(); + NetManager = new NetworkManager(ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>()); + + // Initialize runtime stat collection + if (ConfigurationManager.Configuration.EnableMetrics) { - var pluginBuilder = new StringBuilder(); + DotNetRuntimeStatsBuilder.Default().StartCollecting(); + } - foreach (var plugin in Plugins) - { - pluginBuilder.Append(plugin.Name) - .Append(' ') - .Append(plugin.Version) - .AppendLine(); - } + var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); + HttpPort = networkConfiguration.HttpServerPortNumber; + HttpsPort = networkConfiguration.HttpsPortNumber; - Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString()); + // Safeguard against invalid configuration + if (HttpPort == HttpsPort) + { + HttpPort = NetworkConfiguration.DefaultHttpPort; + HttpsPort = NetworkConfiguration.DefaultHttpsPort; } - DiscoverTypes(); + CertificateInfo = new CertificateInfo + { + Path = networkConfiguration.CertificatePath, + Password = networkConfiguration.CertificatePassword + }; + Certificate = GetCertificate(CertificateInfo); RegisterServices(); - } - public Task ExecuteHttpHandlerAsync(HttpContext context, Func<Task> next) - => _httpServer.RequestHandler(context); + _pluginManager.RegisterServices(ServiceCollection); + } /// <summary> /// Registers services/resources with the service collection that will be available via DI. @@ -511,21 +557,16 @@ namespace Emby.Server.Implementations ServiceCollection.AddMemoryCache(); - ServiceCollection.AddSingleton(ConfigurationManager); + ServiceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager); + ServiceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager); ServiceCollection.AddSingleton<IApplicationHost>(this); - + ServiceCollection.AddSingleton<IPluginManager>(_pluginManager); ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>(); - ServiceCollection.AddSingleton(_fileSystemManager); - ServiceCollection.AddSingleton<TvdbClientManager>(); - - ServiceCollection.AddSingleton<IHttpClient, HttpClientManager.HttpClientManager>(); + ServiceCollection.AddSingleton<TmdbClientManager>(); - ServiceCollection.AddSingleton(_networkManager); - - ServiceCollection.AddSingleton<IIsoManager, IsoManager>(); + ServiceCollection.AddSingleton(NetManager); ServiceCollection.AddSingleton<ITaskManager, TaskManager>(); @@ -541,13 +582,9 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<IZipClient, ZipClient>(); - ServiceCollection.AddSingleton<IHttpResultFactory, HttpResultFactory>(); - ServiceCollection.AddSingleton<IServerApplicationHost>(this); ServiceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton(ServerConfigurationManager); - ServiceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); ServiceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); @@ -559,12 +596,8 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<IAuthenticationRepository, AuthenticationRepository>(); - // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<IDtoService>(provider.GetRequiredService<IDtoService>)); - - // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<EncodingHelper>(provider.GetRequiredService<EncodingHelper>)); ServiceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>(); + ServiceCollection.AddSingleton<EncodingHelper>(); // TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required ServiceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); @@ -578,8 +611,7 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<ISearchEngine, SearchEngine>(); - ServiceCollection.AddSingleton<ServiceController>(); - ServiceCollection.AddSingleton<IHttpServer, HttpListenerHost>(); + ServiceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); ServiceCollection.AddSingleton<IImageProcessor, ImageProcessor>(); @@ -626,18 +658,18 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<ISessionContext, SessionContext>(); ServiceCollection.AddSingleton<IAuthService, AuthService>(); + ServiceCollection.AddSingleton<IQuickConnect, QuickConnectManager>(); ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>(); - ServiceCollection.AddSingleton<IResourceFileManager, ResourceFileManager>(); - ServiceCollection.AddSingleton<EncodingHelper>(); - ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); ServiceCollection.AddSingleton<TranscodingJobHelper>(); ServiceCollection.AddScoped<MediaInfoHelper>(); ServiceCollection.AddScoped<AudioHelper>(); ServiceCollection.AddScoped<DynamicHlsHelper>(); + + ServiceCollection.AddSingleton<IDirectoryService, DirectoryService>(); } /// <summary> @@ -651,8 +683,6 @@ namespace Emby.Server.Implementations _mediaEncoder = Resolve<IMediaEncoder>(); _sessionManager = Resolve<ISessionManager>(); - _httpServer = Resolve<IHttpServer>(); - _httpClient = Resolve<IHttpClient>(); ((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize(); @@ -684,7 +714,7 @@ namespace Emby.Server.Implementations logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); logger.LogInformation("Arguments: {Args}", commandLineArgs); - logger.LogInformation("Operating system: {OS}", OperatingSystem.Name); + logger.LogInformation("Operating system: {OS}", MediaBrowser.Common.System.OperatingSystem.Name); logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture); logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess); logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive); @@ -713,7 +743,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) { @@ -737,7 +767,7 @@ namespace Emby.Server.Implementations { // For now there's no real way to inject these properly BaseItem.Logger = Resolve<ILogger<BaseItem>>(); - BaseItem.ConfigurationManager = ServerConfigurationManager; + BaseItem.ConfigurationManager = ConfigurationManager; BaseItem.LibraryManager = Resolve<ILibraryManager>(); BaseItem.ProviderManager = Resolve<IProviderManager>(); BaseItem.LocalizationManager = Resolve<ILocalizationManager>(); @@ -751,9 +781,7 @@ namespace Emby.Server.Implementations UserView.CollectionManager = Resolve<ICollectionManager>(); BaseItem.MediaSourceManager = Resolve<IMediaSourceManager>(); CollectionFolder.XmlSerializer = _xmlSerializer; - CollectionFolder.JsonSerializer = Resolve<IJsonSerializer>(); CollectionFolder.ApplicationHost = this; - AuthenticatedAttribute.AuthService = Resolve<IAuthService>(); } /// <summary> @@ -761,19 +789,15 @@ namespace Emby.Server.Implementations /// </summary> private void FindParts() { - if (!ServerConfigurationManager.Configuration.IsPortAuthorized) + if (!ConfigurationManager.Configuration.IsPortAuthorized) { - ServerConfigurationManager.Configuration.IsPortAuthorized = true; + ConfigurationManager.Configuration.IsPortAuthorized = true; ConfigurationManager.SaveConfiguration(); } - ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); - _plugins = GetExports<IPlugin>() - .Select(LoadPlugin) - .Where(i => i != null) - .ToArray(); + _pluginManager.CreatePlugins(); - _httpServer.Init(GetExportTypes<IService>(), GetExports<IWebSocketListener>(), GetUrlPrefixes()); + _urlPrefixes = GetUrlPrefixes().ToArray(); Resolve<ILibraryManager>().AddParts( GetExports<IResolverIgnoreRule>(), @@ -798,55 +822,6 @@ namespace Emby.Server.Implementations Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); Resolve<INotificationManager>().AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>()); - - Resolve<IIsoManager>().AddParts(GetExports<IIsoMounter>()); - } - - private IPlugin LoadPlugin(IPlugin plugin) - { - try - { - if (plugin is IPluginAssembly assemblyPlugin) - { - var assembly = plugin.GetType().Assembly; - var assemblyName = assembly.GetName(); - var assemblyFilePath = assembly.Location; - - var dataFolderPath = Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(assemblyFilePath)); - - assemblyPlugin.SetAttributes(assemblyFilePath, dataFolderPath, assemblyName.Version); - - try - { - var idAttributes = assembly.GetCustomAttributes(typeof(GuidAttribute), true); - if (idAttributes.Length > 0) - { - var attribute = (GuidAttribute)idAttributes[0]; - var assemblyId = new Guid(attribute.Value); - - assemblyPlugin.SetId(assemblyId); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error getting plugin Id from {PluginName}.", plugin.GetType().FullName); - } - } - - if (plugin is IHasPluginConfiguration hasPluginConfiguration) - { - hasPluginConfiguration.SetStartupInfo(s => Directory.CreateDirectory(s)); - } - - plugin.RegisterServices(ServiceCollection); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error loading plugin {PluginName}", plugin.GetType().FullName); - return null; - } - - return plugin; } /// <summary> @@ -871,11 +846,13 @@ namespace Emby.Server.Implementations catch (FileNotFoundException ex) { Logger.LogError(ex, "Error getting exported types from {Assembly}", ass.FullName); + _pluginManager.FailPlugin(ass); continue; } catch (TypeLoadException ex) { Logger.LogError(ex, "Error loading types from {Assembly}.", ass.FullName); + _pluginManager.FailPlugin(ass); continue; } @@ -921,33 +898,31 @@ namespace Emby.Server.Implementations protected void OnConfigurationUpdated(object sender, EventArgs e) { var requiresRestart = false; + var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); // Don't do anything if these haven't been set yet if (HttpPort != 0 && HttpsPort != 0) { // Need to restart if ports have changed - if (ServerConfigurationManager.Configuration.HttpServerPortNumber != HttpPort || - ServerConfigurationManager.Configuration.HttpsPortNumber != HttpsPort) + if (networkConfiguration.HttpServerPortNumber != HttpPort || + networkConfiguration.HttpsPortNumber != HttpsPort) { - if (ServerConfigurationManager.Configuration.IsPortAuthorized) + if (ConfigurationManager.Configuration.IsPortAuthorized) { - ServerConfigurationManager.Configuration.IsPortAuthorized = false; - ServerConfigurationManager.SaveConfiguration(); + ConfigurationManager.Configuration.IsPortAuthorized = false; + ConfigurationManager.SaveConfiguration(); requiresRestart = true; } } } - if (!_httpServer.UrlPrefixes.SequenceEqual(GetUrlPrefixes(), StringComparer.OrdinalIgnoreCase)) + if (!_urlPrefixes.SequenceEqual(GetUrlPrefixes(), StringComparer.OrdinalIgnoreCase)) { requiresRestart = true; } - var currentCertPath = CertificateInfo?.Path; - var newCertPath = ServerConfigurationManager.Configuration.CertificatePath; - - if (!string.Equals(currentCertPath, newCertPath, StringComparison.OrdinalIgnoreCase)) + if (ValidateSslCertificate(networkConfiguration)) { requiresRestart = true; } @@ -961,6 +936,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() @@ -1019,24 +1021,9 @@ namespace Emby.Server.Implementations /// <returns>IEnumerable{Assembly}.</returns> protected IEnumerable<Assembly> GetComposablePartAssemblies() { - if (Directory.Exists(ApplicationPaths.PluginsPath)) + foreach (var p in _pluginManager.LoadAssemblies()) { - foreach (var file in Directory.EnumerateFiles(ApplicationPaths.PluginsPath, "*.dll", SearchOption.AllDirectories)) - { - Assembly plugAss; - try - { - plugAss = Assembly.LoadFrom(file); - } - catch (FileLoadException ex) - { - Logger.LogError(ex, "Failed to load assembly {Path}", file); - continue; - } - - Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file); - yield return plugAss; - } + yield return p; } // Include composable parts in the Model assembly @@ -1072,6 +1059,9 @@ namespace Emby.Server.Implementations // Xbmc yield return typeof(ArtistNfoProvider).Assembly; + // Network + yield return typeof(NetworkManager).Assembly; + foreach (var i in GetAssembliesWithPartsInternal()) { yield return i; @@ -1083,13 +1073,10 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the system status. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="source">Where this request originated.</param> /// <returns>SystemInfo.</returns> - public async Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken) + public SystemInfo GetSystemInfo(IPAddress source) { - var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false); - var transcodingTempPath = ConfigurationManager.GetTranscodePath(); - return new SystemInfo { HasPendingRestart = HasPendingRestart, @@ -1104,14 +1091,13 @@ namespace Emby.Server.Implementations ItemsByNamePath = ApplicationPaths.InternalMetadataPath, InternalMetadataPath = ApplicationPaths.InternalMetadataPath, CachePath = ApplicationPaths.CachePath, - OperatingSystem = OperatingSystem.Id.ToString(), - OperatingSystemDisplayName = OperatingSystem.Name, + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), + OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name, CanSelfRestart = CanSelfRestart, CanLaunchWebBrowser = CanLaunchWebBrowser, - HasUpdateAvailable = HasUpdateAvailable, - TranscodingTempPath = transcodingTempPath, + TranscodingTempPath = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, - LocalAddress = localAddress, + LocalAddress = GetSmartApiUrl(source), SupportsLibraryMonitor = true, EncoderLocation = _mediaEncoder.EncoderLocation, SystemArchitecture = RuntimeInformation.OSArchitecture, @@ -1120,221 +1106,117 @@ namespace Emby.Server.Implementations } public IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo() - => _networkManager.GetMacAddresses() + => NetManager.GetMacAddresses() .Select(i => new WakeOnLanInfo(i)) .ToList(); - public async Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken) + public PublicSystemInfo GetPublicSystemInfo(IPAddress address) { - var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false); - return new PublicSystemInfo { Version = ApplicationVersionString, ProductName = ApplicationProductName, Id = SystemId, - OperatingSystem = OperatingSystem.Id.ToString(), + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), ServerName = FriendlyName, - LocalAddress = localAddress + LocalAddress = GetSmartApiUrl(address), + StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted }; } /// <inheritdoc/> - public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.Configuration.EnableHttps; + public bool ListenWithHttps => Certificate != null && ConfigurationManager.GetNetworkConfiguration().EnableHttps; /// <inheritdoc/> - public async Task<string> GetLocalApiUrl(CancellationToken cancellationToken) + public string GetSmartApiUrl(IPAddress remoteAddr, int? port = null) { - try + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - // Return the first matched address, if found, or the first known local address - var addresses = await GetLocalIpAddressesInternal(false, 1, cancellationToken).ConfigureAwait(false); - if (addresses.Count == 0) - { - return null; - } - - return GetLocalApiUrl(addresses[0]); + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - catch (Exception ex) + + string smart = NetManager.GetBindInterface(remoteAddr, out port); + // If the smartAPI doesn't start with http then treat it as a host or ip. + if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - Logger.LogError(ex, "Error getting local Ip address information"); + return smart.Trim('/'); } - return null; + return GetLocalApiUrl(smart.Trim('/'), null, port); } - /// <summary> - /// Removes the scope id from IPv6 addresses. - /// </summary> - /// <param name="address">The IPv6 address.</param> - /// <returns>The IPv6 address without the scope id.</returns> - private ReadOnlySpan<char> RemoveScopeId(ReadOnlySpan<char> address) + /// <inheritdoc/> + public string GetSmartApiUrl(HttpRequest request, int? port = null) { - var index = address.IndexOf('%'); - if (index == -1) + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - return address; + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - return address.Slice(0, index); - } - - /// <inheritdoc /> - public string GetLocalApiUrl(IPAddress ipAddress) - { - if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) + string smart = NetManager.GetBindInterface(request, out port); + // If the smartAPI doesn't start with http then treat it as a host or ip. + if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - var str = RemoveScopeId(ipAddress.ToString()); - Span<char> span = new char[str.Length + 2]; - span[0] = '['; - str.CopyTo(span.Slice(1)); - span[^1] = ']'; - - return GetLocalApiUrl(span); + return smart.Trim('/'); } - return GetLocalApiUrl(ipAddress.ToString()); + return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port); } /// <inheritdoc/> - public string GetLoopbackHttpApiUrl() + public string GetSmartApiUrl(string hostname, int? port = null) { - return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort); - } - - /// <inheritdoc/> - public string GetLocalApiUrl(ReadOnlySpan<char> host, string scheme = null, int? port = null) - { - // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does - // not. For consistency, always trim the trailing slash. - return new UriBuilder + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp), - Host = host.ToString(), - Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort), - Path = ServerConfigurationManager.Configuration.BaseUrl - }.ToString().TrimEnd('/'); - } - - public Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken) - { - return GetLocalIpAddressesInternal(true, 0, cancellationToken); - } - - private async Task<List<IPAddress>> GetLocalIpAddressesInternal(bool allowLoopback, int limit, CancellationToken cancellationToken) - { - var addresses = ServerConfigurationManager - .Configuration - .LocalNetworkAddresses - .Select(x => NormalizeConfiguredLocalAddress(x)) - .Where(i => i != null) - .ToList(); - - if (addresses.Count == 0) - { - addresses.AddRange(_networkManager.GetLocalIpAddresses()); + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - var resultList = new List<IPAddress>(); + string smart = NetManager.GetBindInterface(hostname, out port); - foreach (var address in addresses) + // If the smartAPI doesn't start with http then treat it as a host or ip. + if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - if (!allowLoopback) - { - if (address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback)) - { - continue; - } - } - - if (await IsLocalIpAddressValidAsync(address, cancellationToken).ConfigureAwait(false)) - { - resultList.Add(address); - - if (limit > 0 && resultList.Count >= limit) - { - return resultList; - } - } + return smart.Trim('/'); } - return resultList; + return GetLocalApiUrl(smart.Trim('/'), null, port); } - public IPAddress NormalizeConfiguredLocalAddress(ReadOnlySpan<char> address) + /// <inheritdoc/> + public string GetLoopbackHttpApiUrl() { - var index = address.Trim('/').IndexOf('/'); - if (index != -1) + if (NetManager.IsIP6Enabled) { - address = address.Slice(index + 1); + return GetLocalApiUrl("::1", Uri.UriSchemeHttp, HttpPort); } - if (IPAddress.TryParse(address.Trim('/'), out IPAddress result)) - { - return result; - } - - return null; + return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort); } - private readonly ConcurrentDictionary<string, bool> _validAddressResults = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase); - - private async Task<bool> IsLocalIpAddressValidAsync(IPAddress address, CancellationToken cancellationToken) + /// <inheritdoc/> + public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - if (address.Equals(IPAddress.Loopback) - || address.Equals(IPAddress.IPv6Loopback)) - { - return true; - } - - var apiUrl = GetLocalApiUrl(address) + "/system/ping"; - - if (_validAddressResults.TryGetValue(apiUrl, out var cachedResult)) - { - return cachedResult; - } - - try - { - using (var response = await _httpClient.SendAsync( - new HttpRequestOptions - { - Url = apiUrl, - LogErrorResponseBody = false, - BufferContent = false, - CancellationToken = cancellationToken - }, HttpMethod.Post).ConfigureAwait(false)) - { - using (var reader = new StreamReader(response.Content)) - { - var result = await reader.ReadToEndAsync().ConfigureAwait(false); - var valid = string.Equals(Name, result, StringComparison.OrdinalIgnoreCase); - - _validAddressResults.AddOrUpdate(apiUrl, valid, (k, v) => valid); - Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, valid); - return valid; - } - } - } - catch (OperationCanceledException) - { - Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, "Cancelled"); - throw; - } - catch (Exception ex) + // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does + // not. For consistency, always trim the trailing slash. + return new UriBuilder { - Logger.LogDebug(ex, "Ping test result to {0}. Success: {1}", apiUrl, false); - - _validAddressResults.AddOrUpdate(apiUrl, false, (k, v) => false); - return false; - } + Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp), + Host = hostname, + Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort), + Path = ConfigurationManager.GetNetworkConfiguration().BaseUrl + }.ToString().TrimEnd('/'); } public string FriendlyName => - string.IsNullOrEmpty(ServerConfigurationManager.Configuration.ServerName) + string.IsNullOrEmpty(ConfigurationManager.Configuration.ServerName) ? Environment.MachineName - : ServerConfigurationManager.Configuration.ServerName; + : ConfigurationManager.Configuration.ServerName; /// <summary> /// Shuts down. @@ -1362,37 +1244,20 @@ namespace Emby.Server.Implementations protected abstract void ShutdownInternal(); - public event EventHandler HasUpdateAvailableChanged; - - private bool _hasUpdateAvailable; - - public bool HasUpdateAvailable + public IEnumerable<Assembly> GetApiPluginAssemblies() { - get => _hasUpdateAvailable; - set - { - var fireEvent = value && !_hasUpdateAvailable; - - _hasUpdateAvailable = value; + var assemblies = _allConcreteTypes + .Where(i => typeof(ControllerBase).IsAssignableFrom(i)) + .Select(i => i.Assembly) + .Distinct(); - if (fireEvent) - { - HasUpdateAvailableChanged?.Invoke(this, EventArgs.Empty); - } + foreach (var assembly in assemblies) + { + Logger.LogDebug("Found API endpoints in plugin {Name}", assembly.FullName); + yield return assembly; } } - /// <summary> - /// Removes the plugin. - /// </summary> - /// <param name="plugin">The plugin.</param> - public void RemovePlugin(IPlugin plugin) - { - var list = _plugins.ToList(); - list.Remove(plugin); - _plugins = list.ToArray(); - } - public virtual void LaunchUrl(string url) { if (!CanLaunchWebBrowser) @@ -1423,10 +1288,6 @@ namespace Emby.Server.Implementations } } - public virtual void EnableLoopback(string appName) - { - } - private bool _disposed = false; /// <summary> diff --git a/Emby.Server.Implementations/Browser/BrowserLauncher.cs b/Emby.Server.Implementations/Browser/BrowserLauncher.cs deleted file mode 100644 index f8108d1c2..000000000 --- a/Emby.Server.Implementations/Browser/BrowserLauncher.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Browser -{ - /// <summary> - /// Assists in opening application URLs in an external browser. - /// </summary> - public static class BrowserLauncher - { - /// <summary> - /// Opens the home page of the web client. - /// </summary> - /// <param name="appHost">The app host.</param> - public static void OpenWebApp(IServerApplicationHost appHost) - { - TryOpenUrl(appHost, "/web/index.html"); - } - - /// <summary> - /// Opens the swagger API page. - /// </summary> - /// <param name="appHost">The app host.</param> - public static void OpenSwaggerPage(IServerApplicationHost appHost) - { - TryOpenUrl(appHost, "/api-docs/swagger"); - } - - /// <summary> - /// Opens the specified URL in an external browser window. Any exceptions will be logged, but ignored. - /// </summary> - /// <param name="appHost">The application host.</param> - /// <param name="relativeUrl">The URL to open, relative to the server base URL.</param> - private static void TryOpenUrl(IServerApplicationHost appHost, string relativeUrl) - { - try - { - string baseUrl = appHost.GetLocalApiUrl("localhost"); - appHost.LaunchUrl(baseUrl + relativeUrl); - } - catch (Exception ex) - { - var logger = appHost.Resolve<ILogger<IServerApplicationHost>>(); - logger?.LogError(ex, "Failed to open browser window with URL {URL}", relativeUrl); - } - } - } -} diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 26fc1bee4..aa54510a7 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -1,13 +1,17 @@ +#nullable disable + using System; 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.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -21,7 +25,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 +47,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.Options; /// <summary> /// Initializes a new instance of the <see cref="ChannelManager"/> class. @@ -59,7 +62,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 +72,6 @@ namespace Emby.Server.Implementations.Channels IServerConfigurationManager config, IFileSystem fileSystem, IUserDataManager userDataManager, - IJsonSerializer jsonSerializer, IProviderManager providerManager, IMemoryCache memoryCache) { @@ -81,7 +82,6 @@ namespace Emby.Server.Implementations.Channels _config = config; _fileSystem = fileSystem; _userDataManager = userDataManager; - _jsonSerializer = jsonSerializer; _providerManager = providerManager; _memoryCache = memoryCache; } @@ -250,21 +250,16 @@ namespace Emby.Server.Implementations.Channels var all = channels; var totalCount = all.Count; - if (query.StartIndex.HasValue) - { - all = all.Skip(query.StartIndex.Value).ToList(); - } - - if (query.Limit.HasValue) + if (query.StartIndex.HasValue || query.Limit.HasValue) { - all = all.Take(query.Limit.Value).ToList(); + int startIndex = query.StartIndex ?? 0; + int count = query.Limit == null ? totalCount - startIndex : Math.Min(query.Limit.Value, totalCount - startIndex); + all = all.GetRange(startIndex, count); } - var returnItems = all.ToArray(); - if (query.RefreshLatestChannelItems) { - foreach (var item in returnItems) + foreach (var item in all) { RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult(); } @@ -272,7 +267,7 @@ namespace Emby.Server.Implementations.Channels return new QueryResult<Channel> { - Items = returnItems, + Items = all, TotalRecordCount = totalCount }; } @@ -342,21 +337,23 @@ namespace Emby.Server.Implementations.Channels return GetChannel(GetInternalChannelId(channel.Name)) ?? GetChannel(channel, CancellationToken.None).Result; } - private List<MediaSourceInfo> GetSavedMediaSources(BaseItem item) + private MediaSourceInfo[] GetSavedMediaSources(BaseItem item) { var path = Path.Combine(item.GetInternalMetadataPath(), "channelmediasourceinfos.json"); try { - return _jsonSerializer.DeserializeFromFile<List<MediaSourceInfo>>(path) ?? new List<MediaSourceInfo>(); + var bytes = File.ReadAllBytes(path); + return JsonSerializer.Deserialize<MediaSourceInfo[]>(bytes, _jsonOptions) + ?? Array.Empty<MediaSourceInfo>(); } catch { - return new List<MediaSourceInfo>(); + return Array.Empty<MediaSourceInfo>(); } } - 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"); @@ -375,7 +372,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 /> @@ -543,20 +541,20 @@ namespace Emby.Server.Implementations.Channels return _libraryManager.GetItemIds( new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Channel).Name }, + IncludeItemTypes = new[] { nameof(Channel) }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } - }).Select(i => GetChannelFeatures(i.ToString("N", CultureInfo.InvariantCulture))).ToArray(); + }).Select(i => GetChannelFeatures(i)).ToArray(); } /// <inheritdoc /> - public ChannelFeatures GetChannelFeatures(string id) + public ChannelFeatures GetChannelFeatures(Guid? id) { - if (string.IsNullOrEmpty(id)) + if (!id.HasValue) { throw new ArgumentNullException(nameof(id)); } - var channel = GetChannel(id); + var channel = GetChannel(id.Value); var channelProvider = GetChannelProvider(channel); return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures()); @@ -639,7 +637,7 @@ namespace Emby.Server.Implementations.Channels { var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray(); - if (query.ChannelIds.Length > 0) + if (query.ChannelIds.Count > 0) { // Avoid implicitly captured closure var ids = query.ChannelIds; @@ -817,7 +815,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; @@ -839,7 +838,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; @@ -870,7 +870,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; } @@ -880,17 +880,18 @@ namespace Emby.Server.Implementations.Channels } } - private void CacheResponse(object result, string path) + private async Task CacheResponse(ChannelItemResult 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) { - _logger.LogError(ex, "Error writing to channel cache file: {path}", path); + _logger.LogError(ex, "Error writing to channel cache file: {Path}", path); } } @@ -1181,11 +1182,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/Channels/ChannelPostScanTask.cs b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs index eeb49b8fe..2391eed42 100644 --- a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs +++ b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs @@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.Channels var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Channel).Name }, + IncludeItemTypes = new[] { nameof(Channel) }, ExcludeItemIds = installedChannelIds.ToArray() }); diff --git a/Emby.Server.Implementations/Collections/CollectionImageProvider.cs b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs index c69a07e83..ca8409402 100644 --- a/Emby.Server.Implementations/Collections/CollectionImageProvider.cs +++ b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs @@ -82,9 +82,9 @@ namespace Emby.Server.Implementations.Collections return null; }) .Where(i => i != null) - .GroupBy(x => x.Id) + .GroupBy(x => x!.Id) // We removed the null values .Select(x => x.First()) - .ToList(); + .ToList()!; // Again... the list doesn't contain any null values } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 3011a37e3..8270c2e84 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading; @@ -8,11 +7,9 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -64,13 +61,13 @@ namespace Emby.Server.Implementations.Collections } /// <inheritdoc /> - public event EventHandler<CollectionCreatedEventArgs> CollectionCreated; + public event EventHandler<CollectionCreatedEventArgs>? CollectionCreated; /// <inheritdoc /> - public event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection; + public event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection; /// <inheritdoc /> - public event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection; + public event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection; private IEnumerable<Folder> FindFolders(string path) { @@ -81,14 +78,12 @@ namespace Emby.Server.Implementations.Collections .Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path)); } - internal async Task<Folder> EnsureLibraryFolder(string path, bool createIfNeeded) + internal async Task<Folder?> EnsureLibraryFolder(string path, bool createIfNeeded) { - var existingFolders = FindFolders(path) - .ToList(); - - if (existingFolders.Count > 0) + var existingFolder = FindFolders(path).FirstOrDefault(); + if (existingFolder != null) { - return existingFolders[0]; + return existingFolder; } if (!createIfNeeded) @@ -107,7 +102,7 @@ namespace Emby.Server.Implementations.Collections var name = _localizationManager.GetLocalizedString("Collections"); - await _libraryManager.AddVirtualFolder(name, CollectionType.BoxSets, libraryOptions, true).ConfigureAwait(false); + await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.BoxSets, libraryOptions, true).ConfigureAwait(false); return FindFolders(path).First(); } @@ -117,14 +112,14 @@ namespace Emby.Server.Implementations.Collections return Path.Combine(_appPaths.DataPath, "collections"); } - private Task<Folder> GetCollectionsFolder(bool createIfNeeded) + private Task<Folder?> GetCollectionsFolder(bool createIfNeeded) { return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } private IEnumerable<BoxSet> GetCollections(User user) { - var folder = GetCollectionsFolder(false).Result; + var folder = GetCollectionsFolder(false).GetAwaiter().GetResult(); return folder == null ? Enumerable.Empty<BoxSet>() @@ -165,9 +160,9 @@ namespace Emby.Server.Implementations.Collections DateCreated = DateTime.UtcNow }; - parentFolder.AddChild(collection, CancellationToken.None); + parentFolder.AddChild(collection); - if (options.ItemIdList.Length > 0) + if (options.ItemIdList.Count > 0) { await AddToCollectionAsync( collection.Id, @@ -206,8 +201,7 @@ namespace Emby.Server.Implementations.Collections private async Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> ids, bool fireEvent, MetadataRefreshOptions refreshOptions) { - var collection = _libraryManager.GetItemById(collectionId) as BoxSet; - if (collection == null) + if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { throw new ArgumentException("No collection exists with the supplied Id"); } @@ -251,11 +245,7 @@ namespace Emby.Server.Implementations.Collections if (fireEvent) { - ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs - { - Collection = collection, - ItemsChanged = itemList - }); + ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList)); } } } @@ -263,9 +253,7 @@ namespace Emby.Server.Implementations.Collections /// <inheritdoc /> public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds) { - var collection = _libraryManager.GetItemById(collectionId) as BoxSet; - - if (collection == null) + if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { throw new ArgumentException("No collection exists with the supplied Id"); } @@ -307,11 +295,7 @@ namespace Emby.Server.Implementations.Collections }, RefreshPriority.High); - ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs - { - Collection = collection, - ItemsChanged = itemList - }); + ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList)); } /// <inheritdoc /> @@ -319,34 +303,57 @@ namespace Emby.Server.Implementations.Collections { var results = new Dictionary<Guid, BaseItem>(); - var allBoxsets = GetCollections(user).ToList(); + var allBoxSets = GetCollections(user).ToList(); foreach (var item in items) { - if (!(item is ISupportsBoxSetGrouping)) - { - results[item.Id] = item; - } - else + if (item is ISupportsBoxSetGrouping) { var itemId = item.Id; - var currentBoxSets = allBoxsets - .Where(i => i.ContainsLinkedChildByItemId(itemId)) - .ToList(); + var itemIsInBoxSet = false; + foreach (var boxSet in allBoxSets) + { + if (!boxSet.ContainsLinkedChildByItemId(itemId)) + { + continue; + } + + itemIsInBoxSet = true; + + results.TryAdd(boxSet.Id, boxSet); + } + + // skip any item that is in a box set + if (itemIsInBoxSet) + { + continue; + } + + var alreadyInResults = false; - if (currentBoxSets.Count > 0) + // this is kind of a performance hack because only Video has alternate versions that should be in a box set? + if (item is Video video) { - foreach (var boxset in currentBoxSets) + foreach (var childId in video.GetLocalAlternateVersionIds()) { - results[boxset.Id] = boxset; + if (!results.ContainsKey(childId)) + { + continue; + } + + alreadyInResults = true; + break; } } - else + + if (alreadyInResults) { - results[item.Id] = item; + continue; } } + + results[item.Id] = item; } return results.Values; diff --git a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs index f05a30a89..ff5602f24 100644 --- a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.Globalization; using System.IO; @@ -88,7 +90,6 @@ namespace Emby.Server.Implementations.Configuration var newConfig = (ServerConfiguration)newConfiguration; ValidateMetadataPath(newConfig); - ValidateSslCertificate(newConfig); ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig)); @@ -96,31 +97,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/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index 64ccff53b..01dc728c1 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using Emby.Server.Implementations.HttpServer; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; namespace Emby.Server.Implementations @@ -15,10 +14,10 @@ namespace Emby.Server.Implementations public static Dictionary<string, string> DefaultConfiguration => new Dictionary<string, string> { { HostWebClientKey, bool.TrueString }, - { HttpListenerHost.DefaultRedirectKey, "web/index.html" }, + { DefaultRedirectKey, "web/index.html" }, { FfmpegProbeSizeKey, "1G" }, { FfmpegAnalyzeDurationKey, "200M" }, - { PlaylistsAllowDuplicatesKey, bool.TrueString }, + { PlaylistsAllowDuplicatesKey, bool.FalseString }, { BindToUnixSocketKey, bool.FalseString } }; } diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index fd302d136..4a9b28085 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -1,8 +1,7 @@ -#nullable enable - using System; using System.Collections.Generic; using System.Security.Cryptography; +using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Cryptography; using static MediaBrowser.Common.Cryptography.Constants; @@ -80,7 +79,7 @@ namespace Emby.Server.Implementations.Cryptography throw new CryptographicException($"Requested hash method is not supported: {hashMethod}"); } - using var h = HashAlgorithm.Create(hashMethod); + using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}."); if (salt.Length == 0) { return h.ComputeHash(bytes); diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index 8a3716380..6f23a0888 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -143,12 +145,22 @@ namespace Emby.Server.Implementations.Data public IStatement PrepareStatement(IDatabaseConnection connection, string sql) => connection.PrepareStatement(sql); - public IEnumerable<IStatement> PrepareAll(IDatabaseConnection connection, IEnumerable<string> sql) - => sql.Select(connection.PrepareStatement); + public IStatement[] PrepareAll(IDatabaseConnection connection, IReadOnlyList<string> sql) + { + int len = sql.Count; + IStatement[] statements = new IStatement[len]; + for (int i = 0; i < len; i++) + { + statements[i] = connection.PrepareStatement(sql[i]); + } + + return statements; + } protected bool TableExists(ManagedConnection connection, string name) { - return connection.RunInTransaction(db => + return connection.RunInTransaction( + db => { using (var statement = PrepareStatement(db, "select DISTINCT tbl_name from sqlite_master")) { @@ -171,11 +183,9 @@ namespace Emby.Server.Implementations.Data foreach (var row in connection.Query("PRAGMA table_info(" + table + ")")) { - if (row[1].SQLiteType != SQLiteType.Null) + if (row.TryGetString(1, out var columnName)) { - var name = row[1].ToString(); - - columnNames.Add(name); + columnNames.Add(columnName); } } diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index 5c094ddd2..afc8966f9 100644 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -9,7 +9,7 @@ namespace Emby.Server.Implementations.Data { public class ManagedConnection : IDisposable { - private SQLiteDatabaseConnection _db; + private SQLiteDatabaseConnection? _db; private readonly SemaphoreSlim _writeLock; private bool _disposed = false; @@ -54,12 +54,12 @@ namespace Emby.Server.Implementations.Data return _db.RunInTransaction(action, mode); } - public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql) + public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql) { return _db.Query(sql); } - public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql, params object[] values) + public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql, params object[] values) { return _db.Query(sql, values); } diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index 716e5071d..3289e7609 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -1,7 +1,9 @@ +#nullable disable #pragma warning disable CS1591 using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using SQLitePCL.pretty; @@ -59,11 +61,11 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction(conn => { - conn.ExecuteAll(string.Join(";", queries)); + conn.ExecuteAll(string.Join(';', queries)); }); } - public static Guid ReadGuidFromBlob(this IResultSetValue result) + public static Guid ReadGuidFromBlob(this ResultSetValue result) { return new Guid(result.ToBlob()); } @@ -84,7 +86,7 @@ namespace Emby.Server.Implementations.Data private static string GetDateTimeKindFormat(DateTimeKind kind) => (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal; - public static DateTime ReadDateTime(this IResultSetValue result) + public static DateTime ReadDateTime(this ResultSetValue result) { var dateText = result.ToString(); @@ -95,72 +97,147 @@ namespace Emby.Server.Implementations.Data DateTimeStyles.None).ToUniversalTime(); } - public static DateTime? TryReadDateTime(this IResultSetValue result) + public static bool TryReadDateTime(this IReadOnlyList<ResultSetValue> reader, int index, out DateTime result) { - var dateText = result.ToString(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + var dateText = item.ToString(); if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.None, out var dateTimeResult)) { - return dateTimeResult.ToUniversalTime(); + result = dateTimeResult.ToUniversalTime(); + return true; } - return null; + result = default; + return false; } - public static void Attach(SQLiteDatabaseConnection db, string path, string alias) + public static bool TryGetGuid(this IReadOnlyList<ResultSetValue> reader, int index, out Guid result) { - var commandText = string.Format( - CultureInfo.InvariantCulture, - "attach @path as {0};", - alias); - - using (var statement = db.PrepareStatement(commandText)) + var item = reader[index]; + if (item.IsDbNull()) { - statement.TryBind("@path", path); - statement.MoveNext(); + result = default; + return false; } + + result = item.ReadGuidFromBlob(); + return true; } - public static bool IsDBNull(this IReadOnlyList<IResultSetValue> result, int index) + public static bool IsDbNull(this ResultSetValue result) { - return result[index].SQLiteType == SQLiteType.Null; + return result.SQLiteType == SQLiteType.Null; } - public static string GetString(this IReadOnlyList<IResultSetValue> result, int index) + public static string GetString(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ToString(); } - public static bool GetBoolean(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetString(this IReadOnlyList<ResultSetValue> reader, int index, out string result) + { + result = null; + var item = reader[index]; + if (item.IsDbNull()) + { + return false; + } + + result = item.ToString(); + return true; + } + + public static bool GetBoolean(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ToBool(); } - public static int GetInt32(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetBoolean(this IReadOnlyList<ResultSetValue> reader, int index, out bool result) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToBool(); + return true; + } + + public static bool TryGetInt32(this IReadOnlyList<ResultSetValue> reader, int index, out int result) { - return result[index].ToInt(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToInt(); + return true; } - public static long GetInt64(this IReadOnlyList<IResultSetValue> result, int index) + public static long GetInt64(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ToInt64(); } - public static float GetFloat(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetInt64(this IReadOnlyList<ResultSetValue> reader, int index, out long result) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToInt64(); + return true; + } + + public static bool TryGetSingle(this IReadOnlyList<ResultSetValue> reader, int index, out float result) { - return result[index].ToFloat(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToFloat(); + return true; + } + + public static bool TryGetDouble(this IReadOnlyList<ResultSetValue> reader, int index, out double result) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToDouble(); + return true; } - public static Guid GetGuid(this IReadOnlyList<IResultSetValue> result, int index) + public static Guid GetGuid(this IReadOnlyList<ResultSetValue> result, int index) { return result[index].ReadGuidFromBlob(); } + [Conditional("DEBUG")] private static void CheckName(string name) { -#if DEBUG throw new ArgumentException("Invalid param name: " + name, nameof(name)); -#endif } public static void TryBind(this IStatement statement, string name, double value) @@ -234,7 +311,9 @@ namespace Emby.Server.Implementations.Data { if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) { - bindParam.Bind(value.ToByteArray()); + Span<byte> byteValue = stackalloc byte[16]; + value.TryWriteBytes(byteValue); + bindParam.Bind(byteValue); } else { @@ -362,7 +441,7 @@ namespace Emby.Server.Implementations.Data } } - public static IEnumerable<IReadOnlyList<IResultSetValue>> ExecuteQuery(this IStatement statement) + public static IEnumerable<IReadOnlyList<ResultSetValue>> ExecuteQuery(this IStatement statement) { while (statement.MoveNext()) { diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 5bf740cfc..2cb10765f 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -1,6 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Buffers.Text; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -8,10 +11,12 @@ using System.Linq; using System.Text; using System.Text.Json; using System.Threading; +using Diacritics.Extensions; using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Json; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -40,6 +45,7 @@ namespace Emby.Server.Implementations.Data /// </summary> public class SqliteItemRepository : BaseSqliteRepository, IItemRepository { + private const string FromText = " from TypedBaseItems A"; private const string ChaptersTableName = "Chapters2"; private readonly IServerConfigurationManager _config; @@ -88,7 +94,7 @@ namespace Emby.Server.Implementations.Data _imageProcessor = imageProcessor; _typeMapper = new TypeMapper(); - _jsonOptions = JsonDefaults.GetOptions(); + _jsonOptions = JsonDefaults.Options; DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db"); } @@ -138,7 +144,6 @@ namespace Emby.Server.Implementations.Data "pragma shrink_memory" }; - string[] postQueries = { // obsolete @@ -220,7 +225,8 @@ namespace Emby.Server.Implementations.Data { connection.RunQueries(queries); - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { var existingColumnNames = GetColumnNames(db, "AncestorIds"); AddColumn(db, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); @@ -496,12 +502,13 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { using (var saveImagesStatement = base.PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) { saveImagesStatement.TryBind("@Id", item.Id.ToByteArray()); - saveImagesStatement.TryBind("@Images", SerializeImages(item)); + saveImagesStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); saveImagesStatement.MoveNext(); } @@ -547,7 +554,8 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { SaveItemsInTranscation(db, tuples); }, TransactionMode); @@ -560,7 +568,7 @@ namespace Emby.Server.Implementations.Data { SaveItemCommandText, "delete from AncestorIds where ItemId=@ItemId" - }).ToList(); + }); using (var saveItemStatement = statements[0]) using (var deleteAncestorsStatement = statements[1]) @@ -685,7 +693,7 @@ namespace Emby.Server.Implementations.Data if (item.Genres.Length > 0) { - saveItemStatement.TryBind("@Genres", string.Join("|", item.Genres)); + saveItemStatement.TryBind("@Genres", string.Join('|', item.Genres)); } else { @@ -747,7 +755,7 @@ namespace Emby.Server.Implementations.Data if (item.LockedFields.Length > 0) { - saveItemStatement.TryBind("@LockedFields", string.Join("|", item.LockedFields)); + saveItemStatement.TryBind("@LockedFields", string.Join('|', item.LockedFields)); } else { @@ -756,7 +764,7 @@ namespace Emby.Server.Implementations.Data if (item.Studios.Length > 0) { - saveItemStatement.TryBind("@Studios", string.Join("|", item.Studios)); + saveItemStatement.TryBind("@Studios", string.Join('|', item.Studios)); } else { @@ -783,7 +791,7 @@ namespace Emby.Server.Implementations.Data if (item.Tags.Length > 0) { - saveItemStatement.TryBind("@Tags", string.Join("|", item.Tags)); + saveItemStatement.TryBind("@Tags", string.Join('|', item.Tags)); } else { @@ -805,7 +813,7 @@ namespace Emby.Server.Implementations.Data if (item is Trailer trailer && trailer.TrailerTypes.Length > 0) { - saveItemStatement.TryBind("@TrailerTypes", string.Join("|", trailer.TrailerTypes)); + saveItemStatement.TryBind("@TrailerTypes", string.Join('|', trailer.TrailerTypes)); } else { @@ -895,12 +903,12 @@ namespace Emby.Server.Implementations.Data saveItemStatement.TryBind("@ExternalSeriesId", item.ExternalSeriesId); saveItemStatement.TryBind("@Tagline", item.Tagline); - saveItemStatement.TryBind("@ProviderIds", SerializeProviderIds(item)); - saveItemStatement.TryBind("@Images", SerializeImages(item)); + saveItemStatement.TryBind("@ProviderIds", SerializeProviderIds(item.ProviderIds)); + saveItemStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); if (item.ProductionLocations.Length > 0) { - saveItemStatement.TryBind("@ProductionLocations", string.Join("|", item.ProductionLocations)); + saveItemStatement.TryBind("@ProductionLocations", string.Join('|', item.ProductionLocations)); } else { @@ -909,7 +917,7 @@ namespace Emby.Server.Implementations.Data if (item.ExtraIds.Length > 0) { - saveItemStatement.TryBind("@ExtraIds", string.Join("|", item.ExtraIds)); + saveItemStatement.TryBind("@ExtraIds", string.Join('|', item.ExtraIds)); } else { @@ -929,7 +937,7 @@ namespace Emby.Server.Implementations.Data string artists = null; if (item is IHasArtist hasArtists && hasArtists.Artists.Count > 0) { - artists = string.Join("|", hasArtists.Artists); + artists = string.Join('|', hasArtists.Artists); } saveItemStatement.TryBind("@Artists", artists); @@ -938,7 +946,7 @@ namespace Emby.Server.Implementations.Data if (item is IHasAlbumArtist hasAlbumArtists && hasAlbumArtists.AlbumArtists.Count > 0) { - albumArtists = string.Join("|", hasAlbumArtists.AlbumArtists); + albumArtists = string.Join('|', hasAlbumArtists.AlbumArtists); } saveItemStatement.TryBind("@AlbumArtists", albumArtists); @@ -966,10 +974,10 @@ namespace Emby.Server.Implementations.Data saveItemStatement.MoveNext(); } - private static string SerializeProviderIds(BaseItem item) + internal static string SerializeProviderIds(Dictionary<string, string> providerIds) { StringBuilder str = new StringBuilder(); - foreach (var i in item.ProviderIds) + foreach (var i in providerIds) { // Ideally we shouldn't need this IsNullOrWhiteSpace check, // but we're seeing some cases of bad data slip through @@ -993,35 +1001,25 @@ namespace Emby.Server.Implementations.Data return str.ToString(); } - private static void DeserializeProviderIds(string value, BaseItem item) + internal static void DeserializeProviderIds(string value, IHasProviderIds item) { if (string.IsNullOrWhiteSpace(value)) { return; } - if (item.ProviderIds.Count > 0) - { - return; - } - - var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (var part in parts) + foreach (var part in value.SpanSplit('|')) { - var idParts = part.Split('='); - - if (idParts.Length == 2) + var providerDelimiterIndex = part.IndexOf('='); + if (providerDelimiterIndex != -1 && providerDelimiterIndex == part.LastIndexOf('=')) { - item.SetProviderId(idParts[0], idParts[1]); + item.SetProviderId(part.Slice(0, providerDelimiterIndex).ToString(), part.Slice(providerDelimiterIndex + 1).ToString()); } } } - private string SerializeImages(BaseItem item) + internal string SerializeImages(ItemImageInfo[] images) { - var images = item.ImageInfos; - if (images.Length == 0) { return null; @@ -1043,39 +1041,48 @@ namespace Emby.Server.Implementations.Data return str.ToString(); } - private void DeserializeImages(string value, BaseItem item) + internal ItemImageInfo[] DeserializeImages(string value) { if (string.IsNullOrWhiteSpace(value)) { - return; + return Array.Empty<ItemImageInfo>(); } - if (item.ImageInfos.Length > 0) - { - return; - } + // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed + var valueSpan = value.AsSpan(); + var count = valueSpan.Count('|') + 1; - var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - var list = new List<ItemImageInfo>(); - foreach (var part in parts) + var position = 0; + var result = new ItemImageInfo[count]; + foreach (var part in valueSpan.Split('|')) { var image = ItemImageInfoFromValueString(part); if (image != null) { - list.Add(image); + result[position++] = image; } } - item.ImageInfos = list.ToArray(); + if (position == count) + { + return result; + } + + if (position == 0) + { + return Array.Empty<ItemImageInfo>(); + } + + // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array. + return result[..position]; } - public void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) + private void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) { const char Delimiter = '*'; var path = image.Path ?? string.Empty; - var hash = image.BlurHash ?? string.Empty; bldr.Append(GetPathToSave(path)) .Append(Delimiter) @@ -1085,48 +1092,105 @@ namespace Emby.Server.Implementations.Data .Append(Delimiter) .Append(image.Width) .Append(Delimiter) - .Append(image.Height) - .Append(Delimiter) - // Replace delimiters with other characters. - // This can be removed when we migrate to a proper DB. - .Append(hash.Replace('*', '/').Replace('|', '\\')); + .Append(image.Height); + + var hash = image.BlurHash; + if (!string.IsNullOrEmpty(hash)) + { + bldr.Append(Delimiter) + // Replace delimiters with other characters. + // This can be removed when we migrate to a proper DB. + .Append(hash.Replace('*', '/').Replace('|', '\\')); + } } - public ItemImageInfo ItemImageInfoFromValueString(string value) + internal ItemImageInfo ItemImageInfoFromValueString(ReadOnlySpan<char> value) { - var parts = value.Split(new[] { '*' }, StringSplitOptions.None); + var nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + return null; + } - if (parts.Length < 3) + ReadOnlySpan<char> path = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) { return null; } - var image = new ItemImageInfo(); + ReadOnlySpan<char> dateModified = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + nextSegment = value.Length; + } - image.Path = RestorePath(parts[0]); + ReadOnlySpan<char> imageType = value[..nextSegment]; - if (long.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks)) + var image = new ItemImageInfo + { + Path = RestorePath(path.ToString()) + }; + + if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks)) { image.DateModified = new DateTime(ticks, DateTimeKind.Utc); } - if (Enum.TryParse(parts[2], true, out ImageType type)) + if (Enum.TryParse(imageType.ToString(), true, out ImageType type)) { image.Type = type; } - if (parts.Length >= 5) + // Optional parameters: width*height*blurhash + if (nextSegment + 1 < value.Length - 1) { - if (int.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) - && int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1 || nextSegment == value.Length) + { + return image; + } + + ReadOnlySpan<char> widthSpan = value[..nextSegment]; + + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf('*'); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan<char> heightSpan = value[..nextSegment]; + + if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) + && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) { image.Width = width; image.Height = height; } - if (parts.Length >= 6) + if (nextSegment < value.Length - 1) { - image.BlurHash = parts[5].Replace('/', '*').Replace('\\', '|'); + value = value[(nextSegment + 1)..]; + var length = value.Length; + + Span<char> blurHashSpan = stackalloc char[length]; + for (int i = 0; i < length; i++) + { + var c = value[i]; + blurHashSpan[i] = c switch + { + '/' => '*', + '\\' => '|', + _ => c + }; + } + + image.BlurHash = new string(blurHashSpan); } } @@ -1239,12 +1303,12 @@ namespace Emby.Server.Implementations.Data return true; } - private BaseItem GetItem(IReadOnlyList<IResultSetValue> reader, InternalItemsQuery query) + private BaseItem GetItem(IReadOnlyList<ResultSetValue> reader, InternalItemsQuery query) { return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query)); } - private BaseItem GetItem(IReadOnlyList<IResultSetValue> reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) + private BaseItem GetItem(IReadOnlyList<ResultSetValue> reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) { var typeString = reader.GetString(0); @@ -1289,27 +1353,30 @@ namespace Emby.Server.Implementations.Data if (queryHasStartDate) { - if (!reader.IsDBNull(index)) + if (item is IHasStartDate hasStartDate && reader.TryReadDateTime(index, out var startDate)) { - if (item is IHasStartDate hasStartDate) - { - hasStartDate.StartDate = reader[index].ReadDateTime(); - } + hasStartDate.StartDate = startDate; } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var endDate)) { - item.EndDate = reader[index].TryReadDateTime(); + item.EndDate = endDate; } - index++; - - if (!reader.IsDBNull(index)) + var channelId = reader[index]; + if (!channelId.IsDbNull()) { - item.ChannelId = new Guid(reader.GetString(index)); + if (!Utf8Parser.TryParse(channelId.ToBlob(), out Guid value, out _, standardFormat: 'N')) + { + var str = reader.GetString(index); + Logger.LogWarning("{ChannelId} isn't in the expected format", str); + value = new Guid(str); + } + + item.ChannelId = value; } index++; @@ -1318,33 +1385,25 @@ namespace Emby.Server.Implementations.Data { if (item is IHasProgramAttributes hasProgramAttributes) { - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isMovie)) { - hasProgramAttributes.IsMovie = reader.GetBoolean(index); + hasProgramAttributes.IsMovie = isMovie; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isSeries)) { - hasProgramAttributes.IsSeries = reader.GetBoolean(index); + hasProgramAttributes.IsSeries = isSeries; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var episodeTitle)) { - hasProgramAttributes.EpisodeTitle = reader.GetString(index); + hasProgramAttributes.EpisodeTitle = episodeTitle; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isRepeat)) { - hasProgramAttributes.IsRepeat = reader.GetBoolean(index); + hasProgramAttributes.IsRepeat = isRepeat; } - - index++; } else { @@ -1352,246 +1411,194 @@ namespace Emby.Server.Implementations.Data } } - if (!reader.IsDBNull(index)) + if (reader.TryGetSingle(index++, out var communityRating)) { - item.CommunityRating = reader.GetFloat(index); + item.CommunityRating = communityRating; } - index++; - if (HasField(query, ItemFields.CustomRating)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var customRating)) { - item.CustomRating = reader.GetString(index); + item.CustomRating = customRating; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var indexNumber)) { - item.IndexNumber = reader.GetInt32(index); + item.IndexNumber = indexNumber; } - index++; - if (HasField(query, ItemFields.Settings)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isLocked)) { - item.IsLocked = reader.GetBoolean(index); + item.IsLocked = isLocked; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var preferredMetadataLanguage)) { - item.PreferredMetadataLanguage = reader.GetString(index); + item.PreferredMetadataLanguage = preferredMetadataLanguage; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var preferredMetadataCountryCode)) { - item.PreferredMetadataCountryCode = reader.GetString(index); + item.PreferredMetadataCountryCode = preferredMetadataCountryCode; } - - index++; } if (HasField(query, ItemFields.Width)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var width)) { - item.Width = reader.GetInt32(index); + item.Width = width; } - - index++; } if (HasField(query, ItemFields.Height)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var height)) { - item.Height = reader.GetInt32(index); + item.Height = height; } - - index++; } if (HasField(query, ItemFields.DateLastRefreshed)) { - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateLastRefreshed)) { - item.DateLastRefreshed = reader[index].ReadDateTime(); + item.DateLastRefreshed = dateLastRefreshed; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var name)) { - item.Name = reader.GetString(index); + item.Name = name; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var restorePath)) { - item.Path = RestorePath(reader.GetString(index)); + item.Path = RestorePath(restorePath); } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var premiereDate)) { - item.PremiereDate = reader[index].TryReadDateTime(); + item.PremiereDate = premiereDate; } - index++; - if (HasField(query, ItemFields.Overview)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var overview)) { - item.Overview = reader.GetString(index); + item.Overview = overview; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var parentIndexNumber)) { - item.ParentIndexNumber = reader.GetInt32(index); + item.ParentIndexNumber = parentIndexNumber; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var productionYear)) { - item.ProductionYear = reader.GetInt32(index); + item.ProductionYear = productionYear; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var officialRating)) { - item.OfficialRating = reader.GetString(index); + item.OfficialRating = officialRating; } - index++; - if (HasField(query, ItemFields.SortName)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var forcedSortName)) { - item.ForcedSortName = reader.GetString(index); + item.ForcedSortName = forcedSortName; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt64(index++, out var runTimeTicks)) { - item.RunTimeTicks = reader.GetInt64(index); + item.RunTimeTicks = runTimeTicks; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetInt64(index++, out var size)) { - item.Size = reader.GetInt64(index); + item.Size = size; } - index++; - if (HasField(query, ItemFields.DateCreated)) { - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateCreated)) { - item.DateCreated = reader[index].ReadDateTime(); + item.DateCreated = dateCreated; } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateModified)) { - item.DateModified = reader[index].ReadDateTime(); + item.DateModified = dateModified; } - index++; - - item.Id = reader.GetGuid(index); - index++; + item.Id = reader.GetGuid(index++); if (HasField(query, ItemFields.Genres)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var genres)) { - item.Genres = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Genres = genres.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index++, out var parentId)) { - item.ParentId = reader.GetGuid(index); + item.ParentId = parentId; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var audioString)) { - if (Enum.TryParse(reader.GetString(index), true, out ProgramAudio audio)) + // TODO Span overload coming in the future https://github.com/dotnet/runtime/issues/1916 + if (Enum.TryParse(audioString, true, out ProgramAudio audio)) { item.Audio = audio; } } - index++; - // TODO: Even if not needed by apps, the server needs it internally // But get this excluded from contexts where it is not needed if (hasServiceName) { if (item is LiveTvChannel liveTvChannel) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var serviceName)) { - liveTvChannel.ServiceName = reader.GetString(index); + liveTvChannel.ServiceName = serviceName; } } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isInMixedFolder)) { - item.IsInMixedFolder = reader.GetBoolean(index); + item.IsInMixedFolder = isInMixedFolder; } - index++; - if (HasField(query, ItemFields.DateLastSaved)) { - if (!reader.IsDBNull(index)) + if (reader.TryReadDateTime(index++, out var dateLastSaved)) { - item.DateLastSaved = reader[index].ReadDateTime(); + item.DateLastSaved = dateLastSaved; } - - index++; } if (HasField(query, ItemFields.Settings)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var lockedFields)) { IEnumerable<MetadataField> GetLockedFields(string s) { - foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (Enum.TryParse(i, true, out MetadataField parsedValue)) { @@ -1600,41 +1607,35 @@ namespace Emby.Server.Implementations.Data } } - item.LockedFields = GetLockedFields(reader.GetString(index)).ToArray(); + item.LockedFields = GetLockedFields(lockedFields).ToArray(); } - - index++; } if (HasField(query, ItemFields.Studios)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var studios)) { - item.Studios = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Studios = studios.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } if (HasField(query, ItemFields.Tags)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var tags)) { - item.Tags = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Tags = tags.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } if (hasTrailerTypes) { if (item is Trailer trailer) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var trailerTypes)) { IEnumerable<TrailerType> GetTrailerTypes(string s) { - foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (Enum.TryParse(i, true, out TrailerType parsedValue)) { @@ -1643,7 +1644,7 @@ namespace Emby.Server.Implementations.Data } } - trailer.TrailerTypes = GetTrailerTypes(reader.GetString(index)).ToArray(); + trailer.TrailerTypes = GetTrailerTypes(trailerTypes).ToArray(); } } @@ -1652,19 +1653,17 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.OriginalTitle)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var originalTitle)) { - item.OriginalTitle = reader.GetString(index); + item.OriginalTitle = originalTitle; } - - index++; } if (item is Video video) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var primaryVersionId)) { - video.PrimaryVersionId = reader.GetString(index); + video.PrimaryVersionId = primaryVersionId; } } @@ -1672,40 +1671,34 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.DateLastMediaAdded)) { - if (item is Folder folder && !reader.IsDBNull(index)) + if (item is Folder folder && reader.TryReadDateTime(index, out var dateLastMediaAdded)) { - folder.DateLastMediaAdded = reader[index].TryReadDateTime(); + folder.DateLastMediaAdded = dateLastMediaAdded; } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var album)) { - item.Album = reader.GetString(index); + item.Album = album; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetSingle(index++, out var criticRating)) { - item.CriticRating = reader.GetFloat(index); + item.CriticRating = criticRating; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetBoolean(index++, out var isVirtualItem)) { - item.IsVirtualItem = reader.GetBoolean(index); + item.IsVirtualItem = isVirtualItem; } - index++; - if (item is IHasSeries hasSeriesName) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var seriesName)) { - hasSeriesName.SeriesName = reader.GetString(index); + hasSeriesName.SeriesName = seriesName; } } @@ -1715,15 +1708,15 @@ namespace Emby.Server.Implementations.Data { if (item is Episode episode) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var seasonName)) { - episode.SeasonName = reader.GetString(index); + episode.SeasonName = seasonName; } index++; - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index, out var seasonId)) { - episode.SeasonId = reader.GetGuid(index); + episode.SeasonId = seasonId; } } else @@ -1739,9 +1732,9 @@ namespace Emby.Server.Implementations.Data { if (hasSeries != null) { - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index, out var seriesId)) { - hasSeries.SeriesId = reader.GetGuid(index); + hasSeries.SeriesId = seriesId; } } @@ -1750,56 +1743,48 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.PresentationUniqueKey)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var presentationUniqueKey)) { - item.PresentationUniqueKey = reader.GetString(index); + item.PresentationUniqueKey = presentationUniqueKey; } - - index++; } if (HasField(query, ItemFields.InheritedParentalRatingValue)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var parentalRating)) { - item.InheritedParentalRatingValue = reader.GetInt32(index); + item.InheritedParentalRatingValue = parentalRating; } - - index++; } if (HasField(query, ItemFields.ExternalSeriesId)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var externalSeriesId)) { - item.ExternalSeriesId = reader.GetString(index); + item.ExternalSeriesId = externalSeriesId; } - - index++; } if (HasField(query, ItemFields.Taglines)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var tagLine)) { - item.Tagline = reader.GetString(index); + item.Tagline = tagLine; } - - index++; } - if (!reader.IsDBNull(index)) + if (item.ProviderIds.Count == 0 && reader.TryGetString(index, out var providerIds)) { - DeserializeProviderIds(reader.GetString(index), item); + DeserializeProviderIds(providerIds, item); } index++; if (query.DtoOptions.EnableImages) { - if (!reader.IsDBNull(index)) + if (item.ImageInfos.Length == 0 && reader.TryGetString(index, out var imageInfos)) { - DeserializeImages(reader.GetString(index), item); + item.ImageInfos = DeserializeImages(imageInfos); } index++; @@ -1807,72 +1792,62 @@ namespace Emby.Server.Implementations.Data if (HasField(query, ItemFields.ProductionLocations)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var productionLocations)) { - item.ProductionLocations = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries).ToArray(); + item.ProductionLocations = productionLocations.Split('|', StringSplitOptions.RemoveEmptyEntries); } - - index++; } if (HasField(query, ItemFields.ExtraIds)) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var extraIds)) { - item.ExtraIds = SplitToGuids(reader.GetString(index)); + item.ExtraIds = SplitToGuids(extraIds); } - - index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetInt32(index++, out var totalBitrate)) { - item.TotalBitrate = reader.GetInt32(index); + item.TotalBitrate = totalBitrate; } - index++; - - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var extraTypeString)) { - if (Enum.TryParse(reader.GetString(index), true, out ExtraType extraType)) + if (Enum.TryParse(extraTypeString, true, out ExtraType extraType)) { item.ExtraType = extraType; } } - index++; - if (hasArtistFields) { - if (item is IHasArtist hasArtists && !reader.IsDBNull(index)) + if (item is IHasArtist hasArtists && reader.TryGetString(index, out var artists)) { - hasArtists.Artists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + hasArtists.Artists = artists.Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; - if (item is IHasAlbumArtist hasAlbumArtists && !reader.IsDBNull(index)) + if (item is IHasAlbumArtist hasAlbumArtists && reader.TryGetString(index, out var albumArtists)) { - hasAlbumArtists.AlbumArtists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + hasAlbumArtists.AlbumArtists = albumArtists.Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index++, out var externalId)) { - item.ExternalId = reader.GetString(index); + item.ExternalId = externalId; } - index++; - if (HasField(query, ItemFields.SeriesPresentationUniqueKey)) { if (hasSeries != null) { - if (!reader.IsDBNull(index)) + if (reader.TryGetString(index, out var seriesPresentationUniqueKey)) { - hasSeries.SeriesPresentationUniqueKey = reader.GetString(index); + hasSeries.SeriesPresentationUniqueKey = seriesPresentationUniqueKey; } } @@ -1881,21 +1856,19 @@ namespace Emby.Server.Implementations.Data if (enableProgramAttributes) { - if (item is LiveTvProgram program && !reader.IsDBNull(index)) + if (item is LiveTvProgram program && reader.TryGetString(index, out var showId)) { - program.ShowId = reader.GetString(index); + program.ShowId = showId; } index++; } - if (!reader.IsDBNull(index)) + if (reader.TryGetGuid(index, out var ownerId)) { - item.OwnerId = reader.GetGuid(index); + item.OwnerId = ownerId; } - index++; - return item; } @@ -1975,21 +1948,21 @@ namespace Emby.Server.Implementations.Data /// <param name="reader">The reader.</param> /// <param name="item">The item.</param> /// <returns>ChapterInfo.</returns> - private ChapterInfo GetChapter(IReadOnlyList<IResultSetValue> reader, BaseItem item) + private ChapterInfo GetChapter(IReadOnlyList<ResultSetValue> reader, BaseItem item) { var chapter = new ChapterInfo { StartPositionTicks = reader.GetInt64(0) }; - if (!reader.IsDBNull(1)) + if (reader.TryGetString(1, out var chapterName)) { - chapter.Name = reader.GetString(1); + chapter.Name = chapterName; } - if (!reader.IsDBNull(2)) + if (reader.TryGetString(2, out var imagePath)) { - chapter.ImagePath = reader.GetString(2); + chapter.ImagePath = imagePath; if (!string.IsNullOrEmpty(chapter.ImagePath)) { @@ -2004,9 +1977,9 @@ namespace Emby.Server.Implementations.Data } } - if (!reader.IsDBNull(3)) + if (reader.TryReadDateTime(3, out var imageDateModified)) { - chapter.ImageDateModified = reader[3].ReadDateTime(); + chapter.ImageDateModified = imageDateModified; } return chapter; @@ -2033,7 +2006,8 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { // First delete chapters db.Execute("delete from " + ChaptersTableName + " where ItemId=@ItemId", idBlob); @@ -2113,30 +2087,7 @@ namespace Emby.Server.Implementations.Data || query.IsLiked.HasValue; } - private readonly ItemFields[] _allFields = Enum.GetNames(typeof(ItemFields)) - .Select(i => (ItemFields)Enum.Parse(typeof(ItemFields), i, true)) - .ToArray(); - - private string[] GetColumnNamesFromField(ItemFields field) - { - switch (field) - { - case ItemFields.Settings: - return new[] { "IsLocked", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "LockedFields" }; - case ItemFields.ServiceName: - return new[] { "ExternalServiceId" }; - case ItemFields.SortName: - return new[] { "ForcedSortName" }; - case ItemFields.Taglines: - return new[] { "Tagline" }; - case ItemFields.Tags: - return new[] { "Tags" }; - case ItemFields.IsHD: - return Array.Empty<string>(); - default: - return new[] { field.ToString() }; - } - } + private readonly ItemFields[] _allFields = Enum.GetValues<ItemFields>(); private bool HasField(InternalItemsQuery query, ItemFields name) { @@ -2264,7 +2215,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Contains("Trailer", StringComparer.OrdinalIgnoreCase); } - private static readonly HashSet<string> _artistExcludeParentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Series", @@ -2319,77 +2269,98 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _seriesTypes.Contains(x)); } - private List<string> GetFinalColumnsToSelect(InternalItemsQuery query, IEnumerable<string> startColumns) + private void SetFinalColumnsToSelect(InternalItemsQuery query, List<string> columns) { - var list = startColumns.ToList(); - foreach (var field in _allFields) { if (!HasField(query, field)) { - foreach (var fieldToRemove in GetColumnNamesFromField(field)) + switch (field) { - list.Remove(fieldToRemove); + case ItemFields.Settings: + columns.Remove("IsLocked"); + columns.Remove("PreferredMetadataCountryCode"); + columns.Remove("PreferredMetadataLanguage"); + columns.Remove("LockedFields"); + break; + case ItemFields.ServiceName: + columns.Remove("ExternalServiceId"); + break; + case ItemFields.SortName: + columns.Remove("ForcedSortName"); + break; + case ItemFields.Taglines: + columns.Remove("Tagline"); + break; + case ItemFields.Tags: + columns.Remove("Tags"); + break; + case ItemFields.IsHD: + // do nothing + break; + default: + columns.Remove(field.ToString()); + break; } } } if (!HasProgramAttributes(query)) { - list.Remove("IsMovie"); - list.Remove("IsSeries"); - list.Remove("EpisodeTitle"); - list.Remove("IsRepeat"); - list.Remove("ShowId"); + columns.Remove("IsMovie"); + columns.Remove("IsSeries"); + columns.Remove("EpisodeTitle"); + columns.Remove("IsRepeat"); + columns.Remove("ShowId"); } if (!HasEpisodeAttributes(query)) { - list.Remove("SeasonName"); - list.Remove("SeasonId"); + columns.Remove("SeasonName"); + columns.Remove("SeasonId"); } if (!HasStartDate(query)) { - list.Remove("StartDate"); + columns.Remove("StartDate"); } if (!HasTrailerTypes(query)) { - list.Remove("TrailerTypes"); + columns.Remove("TrailerTypes"); } if (!HasArtistFields(query)) { - list.Remove("AlbumArtists"); - list.Remove("Artists"); + columns.Remove("AlbumArtists"); + columns.Remove("Artists"); } if (!HasSeriesFields(query)) { - list.Remove("SeriesId"); + columns.Remove("SeriesId"); } if (!HasEpisodeAttributes(query)) { - list.Remove("SeasonName"); - list.Remove("SeasonId"); + columns.Remove("SeasonName"); + columns.Remove("SeasonId"); } if (!query.DtoOptions.EnableImages) { - list.Remove("Images"); + columns.Remove("Images"); } if (EnableJoinUserData(query)) { - list.Add("UserDatas.UserId"); - list.Add("UserDatas.lastPlayedDate"); - list.Add("UserDatas.playbackPositionTicks"); - list.Add("UserDatas.playcount"); - list.Add("UserDatas.isFavorite"); - list.Add("UserDatas.played"); - list.Add("UserDatas.rating"); + columns.Add("UserDatas.UserId"); + columns.Add("UserDatas.lastPlayedDate"); + columns.Add("UserDatas.playbackPositionTicks"); + columns.Add("UserDatas.playcount"); + columns.Add("UserDatas.isFavorite"); + columns.Add("UserDatas.played"); + columns.Add("UserDatas.rating"); } if (query.SimilarTo != null) @@ -2401,11 +2372,11 @@ namespace Emby.Server.Implementations.Data if (string.IsNullOrEmpty(item.OfficialRating)) { - builder.Append("((OfficialRating is null) * 10)"); + builder.Append("(OfficialRating is null * 10)"); } else { - builder.Append("((OfficialRating=@ItemOfficialRating) * 10)"); + builder.Append("(OfficialRating=@ItemOfficialRating * 10)"); } if (item.ProductionYear.HasValue) @@ -2414,12 +2385,30 @@ namespace Emby.Server.Implementations.Data builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 5 Else 0 End )"); } - //// genres, tags - builder.Append("+ ((Select count(CleanValue) from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId)) * 10)"); + // genres, tags, studios, person, year? + builder.Append("+ (Select count(1) * 10 from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId))"); + + if (item is MusicArtist) + { + // Match albums where the artist is AlbumArtist against other albums. + // It is assumed that similar albums => similar artists. + builder.Append( + @"+ (WITH artistValues AS ( + SELECT DISTINCT albumValues.CleanValue + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = @SimilarItemId + ), similarArtist AS ( + SELECT albumValues.ItemId + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = A.Guid + ) SELECT COUNT(DISTINCT(CleanValue)) * 10 FROM ItemValues WHERE ItemId IN (SELECT ItemId FROM similarArtist) AND CleanValue IN (SELECT CleanValue FROM artistValues))"); + } builder.Append(") as SimilarityScore"); - list.Add(builder.ToString()); + columns.Add(builder.ToString()); var oldLen = query.ExcludeItemIds.Length; var newLen = oldLen + item.ExtraIds.Length + 1; @@ -2446,10 +2435,8 @@ namespace Emby.Server.Implementations.Data builder.Append(") as SearchScore"); - list.Add(builder.ToString()); + columns.Add(builder.ToString()); } - - return list; } private void BindSearchParams(InternalItemsQuery query, IStatement statement) @@ -2515,31 +2502,25 @@ namespace Emby.Server.Implementations.Data private string GetGroupBy(InternalItemsQuery query) { - var groups = new List<string>(); - - if (EnableGroupByPresentationUniqueKey(query)) + var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(query); + if (enableGroupByPresentationUniqueKey && query.GroupBySeriesPresentationUniqueKey) { - groups.Add("PresentationUniqueKey"); + return " Group by PresentationUniqueKey, SeriesPresentationUniqueKey"; } - if (query.GroupBySeriesPresentationUniqueKey) + if (enableGroupByPresentationUniqueKey) { - groups.Add("SeriesPresentationUniqueKey"); + return " Group by PresentationUniqueKey"; } - if (groups.Count > 0) + if (query.GroupBySeriesPresentationUniqueKey) { - return " Group by " + string.Join(",", groups); + return " Group by SeriesPresentationUniqueKey"; } return string.Empty; } - private string GetFromText(string alias = "A") - { - return " from TypedBaseItems " + alias; - } - public int GetCount(InternalItemsQuery query) { if (query == null) @@ -2557,17 +2538,21 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count(distinct PresentationUniqueKey)" })) - + GetFromText() - + GetJoinUserDataText(query); + var columns = new List<string> { "count(distinct PresentationUniqueKey)" }; + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 256) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandTextBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses); } + var commandText = commandTextBuilder.ToString(); int count; using (var connection = GetConnection(true)) { @@ -2609,20 +2594,23 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, _retriveItemColumns)) - + GetFromText() - + GetJoinUserDataText(query); + var columns = _retriveItemColumns.ToList(); + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 1024) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandTextBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses); } - commandText += GetGroupBy(query) - + GetOrderByText(query); + commandTextBuilder.Append(GetGroupBy(query)) + .Append(GetOrderByText(query)); if (query.Limit.HasValue || query.StartIndex.HasValue) { @@ -2630,15 +2618,18 @@ namespace Emby.Server.Implementations.Data if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" OFFSET ") + .Append(offset); } } + var commandText = commandTextBuilder.ToString(); var items = new List<BaseItem>(); using (var connection = GetConnection(true)) { @@ -2701,87 +2692,22 @@ namespace Emby.Server.Implementations.Data private string FixUnicodeChars(string buffer) { - if (buffer.IndexOf('\u2013', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2013', '-'); // en dash - } - - if (buffer.IndexOf('\u2014', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2014', '-'); // em dash - } - - if (buffer.IndexOf('\u2015', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2015', '-'); // horizontal bar - } - - if (buffer.IndexOf('\u2017', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2017', '_'); // double low line - } - - if (buffer.IndexOf('\u2018', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2018', '\''); // left single quotation mark - } - - if (buffer.IndexOf('\u2019', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2019', '\''); // right single quotation mark - } - - if (buffer.IndexOf('\u201a', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark - } - - if (buffer.IndexOf('\u201b', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark - } - - if (buffer.IndexOf('\u201c', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark - } - - if (buffer.IndexOf('\u201d', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark - } - - if (buffer.IndexOf('\u201e', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark - } - - if (buffer.IndexOf('\u2026', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis - } - - if (buffer.IndexOf('\u2032', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2032', '\''); // prime - } - - if (buffer.IndexOf('\u2033', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u2033', '\"'); // double prime - } - - if (buffer.IndexOf('\u0060', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u0060', '\''); // grave accent - } - - if (buffer.IndexOf('\u00B4', StringComparison.Ordinal) > -1) - { - buffer = buffer.Replace('\u00B4', '\''); // acute accent - } - - return buffer; + buffer = buffer.Replace('\u2013', '-'); // en dash + buffer = buffer.Replace('\u2014', '-'); // em dash + buffer = buffer.Replace('\u2015', '-'); // horizontal bar + buffer = buffer.Replace('\u2017', '_'); // double low line + buffer = buffer.Replace('\u2018', '\''); // left single quotation mark + buffer = buffer.Replace('\u2019', '\''); // right single quotation mark + buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark + buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark + buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark + buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark + buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark + buffer = buffer.Replace("\u2026", "...", StringComparison.Ordinal); // horizontal ellipsis + buffer = buffer.Replace('\u2032', '\''); // prime + buffer = buffer.Replace('\u2033', '\"'); // double prime + buffer = buffer.Replace('\u0060', '\''); // grave accent + return buffer.Replace('\u00B4', '\''); // acute accent } private void AddItem(List<BaseItem> items, BaseItem newItem) @@ -2859,20 +2785,27 @@ namespace Emby.Server.Implementations.Data query.Limit = query.Limit.Value + 4; } - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, _retriveItemColumns)) - + GetFromText() - + GetJoinUserDataText(query); + var columns = _retriveItemColumns.ToList(); + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 512) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); var whereText = whereClauses.Count == 0 ? string.Empty : - " where " + string.Join(" AND ", whereClauses); + string.Join(" AND ", whereClauses); - commandText += whereText - + GetGroupBy(query) - + GetOrderByText(query); + if (!string.IsNullOrEmpty(whereText)) + { + commandTextBuilder.Append(" where ") + .Append(whereText); + } + + commandTextBuilder.Append(GetGroupBy(query)) + .Append(GetOrderByText(query)); if (query.Limit.HasValue || query.StartIndex.HasValue) { @@ -2880,56 +2813,73 @@ namespace Emby.Server.Implementations.Data if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" OFFSET ") + .Append(offset); } } var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; - var statementTexts = new List<string>(); + var itemQuery = string.Empty; + var totalRecordCountQuery = string.Empty; if (!isReturningZeroItems) { - statementTexts.Add(commandText); + itemQuery = commandTextBuilder.ToString(); } if (query.EnableTotalRecordCount) { - commandText = string.Empty; + commandTextBuilder.Clear(); + + commandTextBuilder.Append(" select "); + List<string> columnsToSelect; if (EnableGroupByPresentationUniqueKey(query)) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct PresentationUniqueKey)" }; } else if (query.GroupBySeriesPresentationUniqueKey) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct SeriesPresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct SeriesPresentationUniqueKey)" }; } else { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (guid)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (guid)" }; } - commandText += GetJoinUserDataText(query) - + whereText; - statementTexts.Add(commandText); + SetFinalColumnsToSelect(query, columnsToSelect); + + commandTextBuilder.AppendJoin(',', columnsToSelect) + .Append(FromText) + .Append(GetJoinUserDataText(query)); + if (!string.IsNullOrEmpty(whereText)) + { + commandTextBuilder.Append(" where ") + .Append(whereText); + } + + totalRecordCountQuery = commandTextBuilder.ToString(); } var list = new List<BaseItem>(); var result = new QueryResult<BaseItem>(); using (var connection = GetConnection(true)) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { - var statements = PrepareAll(db, statementTexts).ToList(); + var itemQueryStatement = PrepareStatement(db, itemQuery); + var totalRecordCountQueryStatement = PrepareStatement(db, totalRecordCountQuery); if (!isReturningZeroItems) { - using (var statement = statements[0]) + using (var statement = itemQueryStatement) { if (EnableJoinUserData(query)) { @@ -2959,11 +2909,14 @@ namespace Emby.Server.Implementations.Data } } } + + LogQueryTime("GetItems.ItemQuery", itemQuery, now); } + now = DateTime.UtcNow; if (query.EnableTotalRecordCount) { - using (var statement = statements[statements.Count - 1]) + using (var statement = totalRecordCountQueryStatement) { if (EnableJoinUserData(query)) { @@ -2978,11 +2931,12 @@ namespace Emby.Server.Implementations.Data result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); } + + LogQueryTime("GetItems.TotalRecordCount", totalRecordCountQuery, now); } }, ReadTransactionMode); } - LogQueryTime("GetItems", commandText, now); result.Items = list; return result; } @@ -3018,7 +2972,7 @@ namespace Emby.Server.Implementations.Data return string.Empty; } - return " ORDER BY " + string.Join(",", orderBy.Select(i => + return " ORDER BY " + string.Join(',', orderBy.Select(i => { var columnMap = MapOrderByField(i.Item1, query); @@ -3115,19 +3069,22 @@ namespace Emby.Server.Implementations.Data var now = DateTime.UtcNow; - var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid" })) - + GetFromText() - + GetJoinUserDataText(query); + var columns = new List<string> { "guid" }; + SetFinalColumnsToSelect(query, columns); + var commandTextBuilder = new StringBuilder("select ", 256) + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)); var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandTextBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses); } - commandText += GetGroupBy(query) - + GetOrderByText(query); + commandTextBuilder.Append(GetGroupBy(query)) + .Append(GetOrderByText(query)); if (query.Limit.HasValue || query.StartIndex.HasValue) { @@ -3135,15 +3092,18 @@ namespace Emby.Server.Implementations.Data if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + commandTextBuilder.Append(" OFFSET ") + .Append(offset); } } + var commandText = commandTextBuilder.ToString(); var list = new List<Guid>(); using (var connection = GetConnection(true)) { @@ -3182,7 +3142,9 @@ namespace Emby.Server.Implementations.Data var now = DateTime.UtcNow; - var commandText = "select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid", "path" })) + GetFromText(); + var columns = new List<string> { "guid", "path" }; + SetFinalColumnsToSelect(query, columns); + var commandText = "select " + string.Join(',', columns) + FromText; var whereClauses = GetWhereClauses(query, null); if (whereClauses.Count != 0) @@ -3224,12 +3186,8 @@ namespace Emby.Server.Implementations.Data foreach (var row in statement.ExecuteQuery()) { var id = row.GetGuid(0); - string path = null; - if (!row.IsDBNull(1)) - { - path = row.GetString(1); - } + row.TryGetString(1, out var path); list.Add(new Tuple<Guid, string>(id, path)); } @@ -3262,9 +3220,11 @@ namespace Emby.Server.Implementations.Data var now = DateTime.UtcNow; + var columns = new List<string> { "guid" }; + SetFinalColumnsToSelect(query, columns); var commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "guid" })) - + GetFromText() + + string.Join(',', columns) + + FromText + GetJoinUserDataText(query); var whereClauses = GetWhereClauses(query, null); @@ -3292,7 +3252,6 @@ namespace Emby.Server.Implementations.Data } } - var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; var statementTexts = new List<string>(); @@ -3305,19 +3264,23 @@ namespace Emby.Server.Implementations.Data { commandText = string.Empty; + List<string> columnsToSelect; if (EnableGroupByPresentationUniqueKey(query)) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct PresentationUniqueKey)" }; } else if (query.GroupBySeriesPresentationUniqueKey) { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct SeriesPresentationUniqueKey)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (distinct SeriesPresentationUniqueKey)" }; } else { - commandText += " select " + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (guid)" })) + GetFromText(); + columnsToSelect = new List<string> { "count (guid)" }; } + SetFinalColumnsToSelect(query, columnsToSelect); + commandText += " select " + string.Join(',', columnsToSelect) + FromText; + commandText += GetJoinUserDataText(query) + whereText; statementTexts.Add(commandText); @@ -3327,9 +3290,10 @@ namespace Emby.Server.Implementations.Data var result = new QueryResult<Guid>(); using (var connection = GetConnection(true)) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { - var statements = PrepareAll(db, statementTexts).ToList(); + var statements = PrepareAll(db, statementTexts); if (!isReturningZeroItems) { @@ -3355,7 +3319,7 @@ namespace Emby.Server.Implementations.Data if (query.EnableTotalRecordCount) { - using (var statement = statements[statements.Count - 1]) + using (var statement = statements[statements.Length - 1]) { if (EnableJoinUserData(query)) { @@ -3563,11 +3527,11 @@ namespace Emby.Server.Implementations.Data statement?.TryBind("@IsFolder", query.IsFolder); } - var includeTypes = query.IncludeItemTypes.SelectMany(MapIncludeItemTypes).ToArray(); + var includeTypes = query.IncludeItemTypes.Select(MapIncludeItemTypes).Where(x => x != null).ToArray(); // Only specify excluded types if no included types are specified if (includeTypes.Length == 0) { - var excludeTypes = query.ExcludeItemTypes.SelectMany(MapIncludeItemTypes).ToArray(); + var excludeTypes = query.ExcludeItemTypes.Select(MapIncludeItemTypes).Where(x => x != null).ToArray(); if (excludeTypes.Length == 1) { whereClauses.Add("type<>@type"); @@ -3575,7 +3539,7 @@ namespace Emby.Server.Implementations.Data } else if (excludeTypes.Length > 1) { - var inClause = string.Join(",", excludeTypes.Select(i => "'" + i + "'")); + var inClause = string.Join(',', excludeTypes.Select(i => "'" + i + "'")); whereClauses.Add($"type not in ({inClause})"); } } @@ -3586,18 +3550,18 @@ namespace Emby.Server.Implementations.Data } else if (includeTypes.Length > 1) { - var inClause = string.Join(",", includeTypes.Select(i => "'" + i + "'")); + var inClause = string.Join(',', includeTypes.Select(i => "'" + i + "'")); whereClauses.Add($"type in ({inClause})"); } - if (query.ChannelIds.Length == 1) + if (query.ChannelIds.Count == 1) { whereClauses.Add("ChannelId=@ChannelId"); statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture)); } - else if (query.ChannelIds.Length > 1) + else if (query.ChannelIds.Count > 1) { - var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); + var inClause = string.Join(',', query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); whereClauses.Add($"ChannelId in ({inClause})"); } @@ -3718,26 +3682,31 @@ namespace Emby.Server.Implementations.Data statement?.TryBind("@MaxPremiereDate", query.MaxPremiereDate.Value); } + StringBuilder clauseBuilder = new StringBuilder(); + const string Or = " OR "; + var trailerTypes = query.TrailerTypes; int trailerTypesLen = trailerTypes.Length; if (trailerTypesLen > 0) { - const string Or = " OR "; - StringBuilder clause = new StringBuilder("(", trailerTypesLen * 32); + clauseBuilder.Append('('); + for (int i = 0; i < trailerTypesLen; i++) { var paramName = "@TrailerTypes" + i; - clause.Append("TrailerTypes like ") + clauseBuilder.Append("TrailerTypes like ") .Append(paramName) .Append(Or); statement?.TryBind(paramName, "%" + trailerTypes[i] + "%"); } // Remove last " OR " - clause.Length -= Or.Length; - clause.Append(')'); + clauseBuilder.Length -= Or.Length; + clauseBuilder.Append(')'); - whereClauses.Add(clause.ToString()); + whereClauses.Add(clauseBuilder.ToString()); + + clauseBuilder.Length = 0; } if (query.IsAiring.HasValue) @@ -3757,23 +3726,35 @@ namespace Emby.Server.Implementations.Data } } - if (query.PersonIds.Length > 0) + int personIdsLen = query.PersonIds.Length; + if (personIdsLen > 0) { // TODO: Should this query with CleanName ? - var clauses = new List<string>(); - var index = 0; - foreach (var personId in query.PersonIds) + clauseBuilder.Append('('); + + Span<byte> idBytes = stackalloc byte[16]; + for (int i = 0; i < personIdsLen; i++) { - var paramName = "@PersonId" + index; + string paramName = "@PersonId" + i; + clauseBuilder.Append("(guid in (select itemid from People where Name = (select Name from TypedBaseItems where guid=") + .Append(paramName) + .Append("))) OR "); - clauses.Add("(guid in (select itemid from People where Name = (select Name from TypedBaseItems where guid=" + paramName + ")))"); - statement?.TryBind(paramName, personId.ToByteArray()); - index++; + if (statement != null) + { + query.PersonIds[i].TryWriteBytes(idBytes); + statement.TryBind(paramName, idBytes); + } } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + // Remove last " OR " + clauseBuilder.Length -= Or.Length; + clauseBuilder.Append(')'); + + whereClauses.Add(clauseBuilder.ToString()); + + clauseBuilder.Length = 0; } if (!string.IsNullOrWhiteSpace(query.Person)) @@ -3894,7 +3875,7 @@ namespace Emby.Server.Implementations.Data if (query.IsPlayed.HasValue) { // We should probably figure this out for all folders, but for right now, this is the only place where we need it - if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(Series).Name, StringComparison.OrdinalIgnoreCase)) + if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(Series), StringComparison.OrdinalIgnoreCase)) { if (query.IsPlayed.Value) { @@ -4038,7 +4019,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(clause); } - if (query.GenreIds.Length > 0) + if (query.GenreIds.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -4059,7 +4040,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(clause); } - if (query.Genres.Length > 0) + if (query.Genres.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -4313,7 +4294,7 @@ namespace Emby.Server.Implementations.Data } else if (query.Years.Length > 1) { - var val = string.Join(",", query.Years); + var val = string.Join(',', query.Years); whereClauses.Add("ProductionYear in (" + val + ")"); } @@ -4363,7 +4344,7 @@ namespace Emby.Server.Implementations.Data } else if (queryMediaTypes.Length > 1) { - var val = string.Join(",", queryMediaTypes.Select(i => "'" + i + "'")); + var val = string.Join(',', queryMediaTypes.Select(i => "'" + i + "'")); whereClauses.Add("MediaType in (" + val + ")"); } @@ -4406,7 +4387,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(string.Join(" AND ", excludeIds)); } - if (query.ExcludeProviderIds.Count > 0) + if (query.ExcludeProviderIds != null && query.ExcludeProviderIds.Count > 0) { var excludeIds = new List<string>(); @@ -4436,7 +4417,7 @@ namespace Emby.Server.Implementations.Data } } - if (query.HasAnyProviderId.Count > 0) + if (query.HasAnyProviderId != null && query.HasAnyProviderId.Count > 0) { var hasProviderIds = new List<string>(); @@ -4460,7 +4441,7 @@ namespace Emby.Server.Implementations.Data var paramName = "@HasAnyProviderId" + index; // this is a search for the placeholder - hasProviderIds.Add("ProviderIds like " + paramName + ""); + hasProviderIds.Add("ProviderIds like " + paramName); // this replaces the placeholder with a value, here: %key=val% if (statement != null) @@ -4481,69 +4462,63 @@ namespace Emby.Server.Implementations.Data if (query.HasImdbId.HasValue) { - whereClauses.Add("ProviderIds like '%imdb=%'"); + whereClauses.Add(GetProviderIdClause(query.HasImdbId.Value, "imdb")); } if (query.HasTmdbId.HasValue) { - whereClauses.Add("ProviderIds like '%tmdb=%'"); + whereClauses.Add(GetProviderIdClause(query.HasTmdbId.Value, "tmdb")); } if (query.HasTvdbId.HasValue) { - whereClauses.Add("ProviderIds like '%tvdb=%'"); + whereClauses.Add(GetProviderIdClause(query.HasTvdbId.Value, "tvdb")); } - var includedItemByNameTypes = GetItemByNameTypesInQuery(query).SelectMany(MapIncludeItemTypes).ToList(); - var enableItemsByName = (query.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; - var queryTopParentIds = query.TopParentIds; - if (queryTopParentIds.Length == 1) + if (queryTopParentIds.Length > 0) { - if (enableItemsByName && includedItemByNameTypes.Count == 1) + var includedItemByNameTypes = GetItemByNameTypesInQuery(query); + var enableItemsByName = (query.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0; + + if (queryTopParentIds.Length == 1) { - whereClauses.Add("(TopParentId=@TopParentId or Type=@IncludedItemByNameType)"); - if (statement != null) + if (enableItemsByName && includedItemByNameTypes.Count == 1) { - statement.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + whereClauses.Add("(TopParentId=@TopParentId or Type=@IncludedItemByNameType)"); + statement?.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + } + else if (enableItemsByName && includedItemByNameTypes.Count > 1) + { + var itemByNameTypeVal = string.Join(',', includedItemByNameTypes.Select(i => "'" + i + "'")); + whereClauses.Add("(TopParentId=@TopParentId or Type in (" + itemByNameTypeVal + "))"); + } + else + { + whereClauses.Add("(TopParentId=@TopParentId)"); } - } - else if (enableItemsByName && includedItemByNameTypes.Count > 1) - { - var itemByNameTypeVal = string.Join(",", includedItemByNameTypes.Select(i => "'" + i + "'")); - whereClauses.Add("(TopParentId=@TopParentId or Type in (" + itemByNameTypeVal + "))"); - } - else - { - whereClauses.Add("(TopParentId=@TopParentId)"); - } - if (statement != null) - { - statement.TryBind("@TopParentId", queryTopParentIds[0].ToString("N", CultureInfo.InvariantCulture)); + statement?.TryBind("@TopParentId", queryTopParentIds[0].ToString("N", CultureInfo.InvariantCulture)); } - } - else if (queryTopParentIds.Length > 1) - { - var val = string.Join(",", queryTopParentIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); - - if (enableItemsByName && includedItemByNameTypes.Count == 1) + else if (queryTopParentIds.Length > 1) { - whereClauses.Add("(Type=@IncludedItemByNameType or TopParentId in (" + val + "))"); - if (statement != null) + var val = string.Join(',', queryTopParentIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); + + if (enableItemsByName && includedItemByNameTypes.Count == 1) { - statement.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + whereClauses.Add("(Type=@IncludedItemByNameType or TopParentId in (" + val + "))"); + statement?.TryBind("@IncludedItemByNameType", includedItemByNameTypes[0]); + } + else if (enableItemsByName && includedItemByNameTypes.Count > 1) + { + var itemByNameTypeVal = string.Join(',', includedItemByNameTypes.Select(i => "'" + i + "'")); + whereClauses.Add("(Type in (" + itemByNameTypeVal + ") or TopParentId in (" + val + "))"); + } + else + { + whereClauses.Add("TopParentId in (" + val + ")"); } - } - else if (enableItemsByName && includedItemByNameTypes.Count > 1) - { - var itemByNameTypeVal = string.Join(",", includedItemByNameTypes.Select(i => "'" + i + "'")); - whereClauses.Add("(Type in (" + itemByNameTypeVal + ") or TopParentId in (" + val + "))"); - } - else - { - whereClauses.Add("TopParentId in (" + val + ")"); } } @@ -4559,7 +4534,7 @@ namespace Emby.Server.Implementations.Data if (query.AncestorIds.Length > 1) { - var inClause = string.Join(",", query.AncestorIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); + var inClause = string.Join(',', query.AncestorIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorIdText in ({0}))", inClause)); } @@ -4731,33 +4706,48 @@ namespace Emby.Server.Implementations.Data return whereClauses; } + /// <summary> + /// Formats a where clause for the specified provider. + /// </summary> + /// <param name="includeResults">Whether or not to include items with this provider's ids.</param> + /// <param name="provider">Provider name.</param> + /// <returns>Formatted SQL clause.</returns> + private string GetProviderIdClause(bool includeResults, string provider) + { + return string.Format( + CultureInfo.InvariantCulture, + "ProviderIds {0} like '%{1}=%'", + includeResults ? string.Empty : "not", + provider); + } + private List<string> GetItemByNameTypesInQuery(InternalItemsQuery query) { var list = new List<string>(); - if (IsTypeInQuery(typeof(Person).Name, query)) + if (IsTypeInQuery(nameof(Person), query)) { - list.Add(typeof(Person).Name); + list.Add(typeof(Person).FullName); } - if (IsTypeInQuery(typeof(Genre).Name, query)) + if (IsTypeInQuery(nameof(Genre), query)) { - list.Add(typeof(Genre).Name); + list.Add(typeof(Genre).FullName); } - if (IsTypeInQuery(typeof(MusicGenre).Name, query)) + if (IsTypeInQuery(nameof(MusicGenre), query)) { - list.Add(typeof(MusicGenre).Name); + list.Add(typeof(MusicGenre).FullName); } - if (IsTypeInQuery(typeof(MusicArtist).Name, query)) + if (IsTypeInQuery(nameof(MusicArtist), query)) { - list.Add(typeof(MusicArtist).Name); + list.Add(typeof(MusicArtist).FullName); } - if (IsTypeInQuery(typeof(Studio).Name, query)) + if (IsTypeInQuery(nameof(Studio), query)) { - list.Add(typeof(Studio).Name); + list.Add(typeof(Studio).FullName); } return list; @@ -4810,17 +4800,12 @@ namespace Emby.Server.Implementations.Data return true; } - var types = new[] - { - typeof(Episode).Name, - typeof(Video).Name, - typeof(Movie).Name, - typeof(MusicVideo).Name, - typeof(Series).Name, - typeof(Season).Name - }; - - if (types.Any(i => query.IncludeItemTypes.Contains(i, StringComparer.OrdinalIgnoreCase))) + if (query.IncludeItemTypes.Contains(nameof(Episode), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Video), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Movie), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(MusicVideo), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Series), StringComparer.OrdinalIgnoreCase) + || query.IncludeItemTypes.Contains(nameof(Season), StringComparer.OrdinalIgnoreCase)) { return true; } @@ -4862,15 +4847,10 @@ namespace Emby.Server.Implementations.Data typeof(AggregateFolder) }; - public void UpdateInheritedValues(CancellationToken cancellationToken) - { - UpdateInheritedTags(cancellationToken); - } - - private void UpdateInheritedTags(CancellationToken cancellationToken) + public void UpdateInheritedValues() { string sql = string.Join( - ";", + ';', new string[] { "delete from itemvalues where type = 6", @@ -4885,44 +4865,46 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { connection.ExecuteAll(sql); }, TransactionMode); } } - private static Dictionary<string, string[]> GetTypeMapDictionary() + private static Dictionary<string, string> GetTypeMapDictionary() { - var dict = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase); + var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (var t in _knownTypes) { - dict[t.Name] = new[] { t.FullName }; + dict[t.Name] = t.FullName ; } - dict["Program"] = new[] { typeof(LiveTvProgram).FullName }; - dict["TvChannel"] = new[] { typeof(LiveTvChannel).FullName }; + dict["Program"] = typeof(LiveTvProgram).FullName; + dict["TvChannel"] = typeof(LiveTvChannel).FullName; return dict; } // Not crazy about having this all the way down here, but at least it's in one place - private readonly Dictionary<string, string[]> _types = GetTypeMapDictionary(); + private readonly Dictionary<string, string> _types = GetTypeMapDictionary(); - private string[] MapIncludeItemTypes(string value) + private string MapIncludeItemTypes(string value) { - if (_types.TryGetValue(value, out string[] result)) + if (_types.TryGetValue(value, out string result)) { return result; } if (IsValidType(value)) { - return new[] { value }; + return value; } - return Array.Empty<string>(); + Logger.LogWarning("Unknown item type: {ItemType}", value); + return null; } public void DeleteItem(Guid id) @@ -4936,7 +4918,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { var idBlob = id.ToByteArray(); @@ -4980,26 +4963,26 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select Distinct Name from People"; + var commandText = new StringBuilder("select Distinct p.Name from People p"); var whereClauses = GetPeopleWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandText.Append(" where ").Append(string.Join(" AND ", whereClauses)); } - commandText += " order by ListOrder"; + commandText.Append(" order by ListOrder"); if (query.Limit > 0) { - commandText += " LIMIT " + query.Limit; + commandText.Append(" LIMIT ").Append(query.Limit); } using (var connection = GetConnection(true)) { var list = new List<string>(); - using (var statement = PrepareStatement(connection, commandText)) + using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params GetPeopleWhereClauses(query, statement); @@ -5023,7 +5006,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People"; + var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People p"; var whereClauses = GetPeopleWhereClauses(query, null); @@ -5062,22 +5045,26 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type { var whereClauses = new List<string>(); + if (query.User != null && query.IsFavorite.HasValue) + { + whereClauses.Add(@"p.Name IN ( +SELECT Name FROM TypedBaseItems WHERE UserDataKey IN ( +SELECT key FROM UserDatas WHERE isFavorite=@IsFavorite AND userId=@UserId) +AND Type = @InternalPersonType)"); + statement?.TryBind("@IsFavorite", query.IsFavorite.Value); + statement?.TryBind("@InternalPersonType", typeof(Person).FullName); + } + if (!query.ItemId.Equals(Guid.Empty)) { whereClauses.Add("ItemId=@ItemId"); - if (statement != null) - { - statement.TryBind("@ItemId", query.ItemId.ToByteArray()); - } + statement?.TryBind("@ItemId", query.ItemId.ToByteArray()); } if (!query.AppearsInItemId.Equals(Guid.Empty)) { - whereClauses.Add("Name in (Select Name from People where ItemId=@AppearsInItemId)"); - if (statement != null) - { - statement.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); - } + whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)"); + statement?.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); } var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList(); @@ -5085,14 +5072,11 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryPersonTypes.Count == 1) { whereClauses.Add("PersonType=@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryPersonTypes[0]); - } + statement?.TryBind("@PersonType", queryPersonTypes[0]); } else if (queryPersonTypes.Count > 1) { - var val = string.Join(",", queryPersonTypes.Select(i => "'" + i + "'")); + var val = string.Join(',', queryPersonTypes.Select(i => "'" + i + "'")); whereClauses.Add("PersonType in (" + val + ")"); } @@ -5102,14 +5086,11 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryExcludePersonTypes.Count == 1) { whereClauses.Add("PersonType<>@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryExcludePersonTypes[0]); - } + statement?.TryBind("@PersonType", queryExcludePersonTypes[0]); } else if (queryExcludePersonTypes.Count > 1) { - var val = string.Join(",", queryExcludePersonTypes.Select(i => "'" + i + "'")); + var val = string.Join(',', queryExcludePersonTypes.Select(i => "'" + i + "'")); whereClauses.Add("PersonType not in (" + val + ")"); } @@ -5117,19 +5098,18 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.MaxListOrder.HasValue) { whereClauses.Add("ListOrder<=@MaxListOrder"); - if (statement != null) - { - statement.TryBind("@MaxListOrder", query.MaxListOrder.Value); - } + statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value); } if (!string.IsNullOrWhiteSpace(query.NameContains)) { - whereClauses.Add("Name like @NameContains"); - if (statement != null) - { - statement.TryBind("@NameContains", "%" + query.NameContains + "%"); - } + whereClauses.Add("p.Name like @NameContains"); + statement?.TryBind("@NameContains", "%" + query.NameContains + "%"); + } + + if (query.User != null) + { + statement?.TryBind("@UserId", query.User.InternalId); } return whereClauses; @@ -5149,7 +5129,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var itemIdBlob = itemId.ToByteArray(); + Span<byte> itemIdBlob = stackalloc byte[16]; + itemId.TryWriteBytes(itemIdBlob); // First delete deleteAncestorsStatement.Reset(); @@ -5165,17 +5146,15 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type for (var i = 0; i < ancestorIds.Count; i++) { - if (i > 0) - { - insertText.Append(','); - } - insertText.AppendFormat( CultureInfo.InvariantCulture, - "(@ItemId, @AncestorId{0}, @AncestorIdText{0})", + "(@ItemId, @AncestorId{0}, @AncestorIdText{0}),", i.ToString(CultureInfo.InvariantCulture)); } + // Remove last , + insertText.Length--; + using (var statement = PrepareStatement(db, insertText.ToString())) { statement.TryBind("@ItemId", itemIdBlob); @@ -5185,8 +5164,9 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type var index = i.ToString(CultureInfo.InvariantCulture); var ancestorId = ancestorIds[i]; + ancestorId.TryWriteBytes(itemIdBlob); - statement.TryBind("@AncestorId" + index, ancestorId.ToByteArray()); + statement.TryBind("@AncestorId" + index, itemIdBlob); statement.TryBind("@AncestorIdText" + index, ancestorId.ToString("N", CultureInfo.InvariantCulture)); } @@ -5227,64 +5207,87 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type public List<string> GetStudioNames() { - return GetItemValueNames(new[] { 3 }, new List<string>(), new List<string>()); + return GetItemValueNames(new[] { 3 }, Array.Empty<string>(), Array.Empty<string>()); } public List<string> GetAllArtistNames() { - return GetItemValueNames(new[] { 0, 1 }, new List<string>(), new List<string>()); + return GetItemValueNames(new[] { 0, 1 }, Array.Empty<string>(), Array.Empty<string>()); } public List<string> GetMusicGenreNames() { - return GetItemValueNames(new[] { 2 }, new List<string> { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist" }, new List<string>()); + return GetItemValueNames( + new[] { 2 }, + new string[] + { + typeof(Audio).FullName, + typeof(MusicVideo).FullName, + typeof(MusicAlbum).FullName, + typeof(MusicArtist).FullName + }, + Array.Empty<string>()); } public List<string> GetGenreNames() { - return GetItemValueNames(new[] { 2 }, new List<string>(), new List<string> { "Audio", "MusicVideo", "MusicAlbum", "MusicArtist" }); + return GetItemValueNames( + new[] { 2 }, + Array.Empty<string>(), + new string[] + { + typeof(Audio).FullName, + typeof(MusicVideo).FullName, + typeof(MusicAlbum).FullName, + typeof(MusicArtist).FullName + }); } - private List<string> GetItemValueNames(int[] itemValueTypes, List<string> withItemTypes, List<string> excludeItemTypes) + private List<string> GetItemValueNames(int[] itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) { CheckDisposed(); - withItemTypes = withItemTypes.SelectMany(MapIncludeItemTypes).ToList(); - excludeItemTypes = excludeItemTypes.SelectMany(MapIncludeItemTypes).ToList(); - var now = DateTime.UtcNow; - var typeClause = itemValueTypes.Length == 1 ? - ("Type=" + itemValueTypes[0].ToString(CultureInfo.InvariantCulture)) : - ("Type in (" + string.Join(",", itemValueTypes.Select(i => i.ToString(CultureInfo.InvariantCulture))) + ")"); - - var commandText = "Select Value From ItemValues where " + typeClause; + var stringBuilder = new StringBuilder("Select Value From ItemValues where Type", 128); + if (itemValueTypes.Length == 1) + { + stringBuilder.Append('=') + .Append(itemValueTypes[0]); + } + else + { + stringBuilder.Append(" in (") + .AppendJoin(',', itemValueTypes) + .Append(')'); + } if (withItemTypes.Count > 0) { - var typeString = string.Join(",", withItemTypes.Select(i => "'" + i + "'")); - commandText += " AND ItemId In (select guid from typedbaseitems where type in (" + typeString + "))"; + stringBuilder.Append(" AND ItemId In (select guid from typedbaseitems where type in (") + .AppendJoinInSingleQuotes(',', withItemTypes) + .Append("))"); } if (excludeItemTypes.Count > 0) { - var typeString = string.Join(",", excludeItemTypes.Select(i => "'" + i + "'")); - commandText += " AND ItemId not In (select guid from typedbaseitems where type in (" + typeString + "))"; + stringBuilder.Append(" AND ItemId not In (select guid from typedbaseitems where type in (") + .AppendJoinInSingleQuotes(',', excludeItemTypes) + .Append("))"); } - commandText += " Group By CleanValue"; + stringBuilder.Append(" Group By CleanValue"); + var commandText = stringBuilder.ToString(); var list = new List<string>(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - using (var statement = PrepareStatement(connection, commandText)) + foreach (var row in statement.ExecuteQuery()) { - foreach (var row in statement.ExecuteQuery()) + if (row.TryGetString(0, out var result)) { - if (!row.IsDBNull(0)) - { - list.Add(row.GetString(0)); - } + list.Add(result); } } } @@ -5310,18 +5313,19 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type var now = DateTime.UtcNow; var typeClause = itemValueTypes.Length == 1 ? - ("Type=" + itemValueTypes[0].ToString(CultureInfo.InvariantCulture)) : - ("Type in (" + string.Join(",", itemValueTypes.Select(i => i.ToString(CultureInfo.InvariantCulture))) + ")"); + ("Type=" + itemValueTypes[0]) : + ("Type in (" + string.Join(',', itemValueTypes) + ")"); InternalItemsQuery typeSubQuery = null; - Dictionary<string, string> itemCountColumns = null; + string itemCountColumns = null; + var stringBuilder = new StringBuilder(1024); var typesToCount = query.IncludeItemTypes; if (typesToCount.Length > 0) { - var itemCountColumnQuery = "select group_concat(type, '|')" + GetFromText("B"); + stringBuilder.Append("(select group_concat(type, '|') from TypedBaseItems B"); typeSubQuery = new InternalItemsQuery(query.User) { @@ -5337,20 +5341,22 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type }; var whereClauses = GetWhereClauses(typeSubQuery, null); - whereClauses.Add("guid in (select ItemId from ItemValues where ItemValues.CleanValue=A.CleanName AND " + typeClause + ")"); - - itemCountColumnQuery += " where " + string.Join(" AND ", whereClauses); + stringBuilder.Append(" where ") + .AppendJoin(" AND ", whereClauses) + .Append(" AND ") + .Append("guid in (select ItemId from ItemValues where ItemValues.CleanValue=A.CleanName AND ") + .Append(typeClause) + .Append(")) as itemTypes"); - itemCountColumns = new Dictionary<string, string>() - { - { "itemTypes", "(" + itemCountColumnQuery + ") as itemTypes"} - }; + itemCountColumns = stringBuilder.ToString(); + stringBuilder.Clear(); } List<string> columns = _retriveItemColumns.ToList(); - if (itemCountColumns != null) + // Unfortunately we need to add it to columns to ensure the order of the columns in the select + if (!string.IsNullOrEmpty(itemCountColumns)) { - columns.AddRange(itemCountColumns.Values); + columns.Add(itemCountColumns); } // do this first before calling GetFinalColumnsToSelect, otherwise ExcludeItemIds will be set by SimilarTo @@ -5363,7 +5369,6 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type ItemIds = query.ItemIds, TopParentIds = query.TopParentIds, ParentId = query.ParentId, - IsPlayed = query.IsPlayed, IsAiring = query.IsAiring, IsMovie = query.IsMovie, IsSports = query.IsSports, @@ -5372,23 +5377,24 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type IsSeries = query.IsSeries }; - columns = GetFinalColumnsToSelect(query, columns); - - var commandText = "select " - + string.Join(",", columns) - + GetFromText() - + GetJoinUserDataText(query); + SetFinalColumnsToSelect(query, columns); var innerWhereClauses = GetWhereClauses(innerQuery, null); - var innerWhereText = innerWhereClauses.Count == 0 ? - string.Empty : - " where " + string.Join(" AND ", innerWhereClauses); + stringBuilder.Append(" where Type=@SelectType And CleanName In (Select CleanValue from ItemValues where ") + .Append(typeClause) + .Append(" AND ItemId in (select guid from TypedBaseItems"); + if (innerWhereClauses.Count > 0) + { + stringBuilder.Append(" where ") + .AppendJoin(" AND ", innerWhereClauses); + } - var whereText = " where Type=@SelectType And CleanName In (Select CleanValue from ItemValues where " + typeClause + " AND ItemId in (select guid from TypedBaseItems" + innerWhereText + "))"; + stringBuilder.Append("))"); var outerQuery = new InternalItemsQuery(query.User) { + IsPlayed = query.IsPlayed, IsFavorite = query.IsFavorite, IsFavoriteOrLiked = query.IsFavoriteOrLiked, IsLiked = query.IsLiked, @@ -5398,6 +5404,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type NameStartsWithOrGreater = query.NameStartsWithOrGreater, Tags = query.Tags, OfficialRatings = query.OfficialRatings, + StudioIds = query.StudioIds, GenreIds = query.GenreIds, Genres = query.Genres, Years = query.Years, @@ -5408,21 +5415,31 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type }; var outerWhereClauses = GetWhereClauses(outerQuery, null); - if (outerWhereClauses.Count != 0) { - whereText += " AND " + string.Join(" AND ", outerWhereClauses); + stringBuilder.Append(" AND ") + .AppendJoin(" AND ", outerWhereClauses); } - commandText += whereText + " group by PresentationUniqueKey"; + var whereText = stringBuilder.ToString(); + stringBuilder.Clear(); + + stringBuilder.Append("select ") + .AppendJoin(',', columns) + .Append(FromText) + .Append(GetJoinUserDataText(query)) + .Append(whereText) + .Append(" group by PresentationUniqueKey"); - if (query.SimilarTo != null || !string.IsNullOrEmpty(query.SearchTerm)) + if (query.OrderBy.Count != 0 + || query.SimilarTo != null + || !string.IsNullOrEmpty(query.SearchTerm)) { - commandText += GetOrderByText(query); + stringBuilder.Append(GetOrderByText(query)); } else { - commandText += " order by SortName"; + stringBuilder.Append(" order by SortName"); } if (query.Limit.HasValue || query.StartIndex.HasValue) @@ -5431,32 +5448,39 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.Limit.HasValue || offset > 0) { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); + stringBuilder.Append(" LIMIT ") + .Append(query.Limit ?? int.MaxValue); } if (offset > 0) { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); + stringBuilder.Append(" OFFSET ") + .Append(offset); } } var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; - var statementTexts = new List<string>(); + string commandText = string.Empty; + if (!isReturningZeroItems) { - statementTexts.Add(commandText); + commandText = stringBuilder.ToString(); } + string countText = string.Empty; if (query.EnableTotalRecordCount) { - var countText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) - + GetFromText() - + GetJoinUserDataText(query) - + whereText; + stringBuilder.Clear(); + var columnsToSelect = new List<string> { "count (distinct PresentationUniqueKey)" }; + SetFinalColumnsToSelect(query, columnsToSelect); + stringBuilder.Append("select ") + .AppendJoin(',', columnsToSelect) + .Append(FromText) + .Append(GetJoinUserDataText(query)) + .Append(whereText); - statementTexts.Add(countText); + countText = stringBuilder.ToString(); } var list = new List<(BaseItem, ItemCounts)>(); @@ -5466,11 +5490,9 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type connection.RunInTransaction( db => { - var statements = PrepareAll(db, statementTexts).ToList(); - if (!isReturningZeroItems) { - using (var statement = statements[0]) + using (var statement = PrepareStatement(db, commandText)) { statement.TryBind("@SelectType", returnType); if (EnableJoinUserData(query)) @@ -5511,13 +5533,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.EnableTotalRecordCount) { - commandText = "select " - + string.Join(",", GetFinalColumnsToSelect(query, new[] { "count (distinct PresentationUniqueKey)" })) - + GetFromText() - + GetJoinUserDataText(query) - + whereText; - - using (var statement = statements[statements.Count - 1]) + using (var statement = PrepareStatement(db, countText)) { statement.TryBind("@SelectType", returnType); if (EnableJoinUserData(query)) @@ -5554,7 +5570,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return result; } - private ItemCounts GetItemCounts(IReadOnlyList<IResultSetValue> reader, int countStartColumn, string[] typesToCount) + private static ItemCounts GetItemCounts(IReadOnlyList<ResultSetValue> reader, int countStartColumn, string[] typesToCount) { var counts = new ItemCounts(); @@ -5563,51 +5579,43 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return counts; } - var typeString = reader.IsDBNull(countStartColumn) ? null : reader.GetString(countStartColumn); - - if (string.IsNullOrWhiteSpace(typeString)) + if (!reader.TryGetString(countStartColumn, out var typeString)) { return counts; } - var allTypes = typeString.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) - .ToLookup(x => x); - - foreach (var type in allTypes) + foreach (var typeName in typeString.AsSpan().Split('|')) { - var value = type.Count(); - var typeName = type.Key; - - if (string.Equals(typeName, typeof(Series).FullName, StringComparison.OrdinalIgnoreCase)) + if (typeName.Equals(typeof(Series).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.SeriesCount = value; + counts.SeriesCount++; } - else if (string.Equals(typeName, typeof(Episode).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Episode).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.EpisodeCount = value; + counts.EpisodeCount++; } - else if (string.Equals(typeName, typeof(Movie).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Movie).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.MovieCount = value; + counts.MovieCount++; } - else if (string.Equals(typeName, typeof(MusicAlbum).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(MusicAlbum).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.AlbumCount = value; + counts.AlbumCount++; } - else if (string.Equals(typeName, typeof(MusicArtist).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(MusicArtist).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.ArtistCount = value; + counts.ArtistCount++; } - else if (string.Equals(typeName, typeof(Audio).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Audio).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.SongCount = value; + counts.SongCount++; } - else if (string.Equals(typeName, typeof(Trailer).FullName, StringComparison.OrdinalIgnoreCase)) + else if (typeName.Equals(typeof(Trailer).FullName, StringComparison.OrdinalIgnoreCase)) { - counts.TrailerCount = value; + counts.TrailerCount++; } - counts.ItemCount += value; + counts.ItemCount++; } return counts; @@ -5730,7 +5738,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { var itemIdBlob = itemId.ToByteArray(); @@ -5755,7 +5764,10 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type var endIndex = Math.Min(people.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) { - insertText.AppendFormat("(@ItemId, @Name{0}, @Role{0}, @PersonType{0}, @SortOrder{0}, @ListOrder{0}),", i.ToString(CultureInfo.InvariantCulture)); + insertText.AppendFormat( + CultureInfo.InvariantCulture, + "(@ItemId, @Name{0}, @Role{0}, @PersonType{0}, @SortOrder{0}, @ListOrder{0}),", + i.ToString(CultureInfo.InvariantCulture)); } // Remove last comma @@ -5789,7 +5801,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } } - private PersonInfo GetPerson(IReadOnlyList<IResultSetValue> reader) + private PersonInfo GetPerson(IReadOnlyList<ResultSetValue> reader) { var item = new PersonInfo { @@ -5797,19 +5809,19 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type Name = reader.GetString(1) }; - if (!reader.IsDBNull(2)) + if (reader.TryGetString(2, out var role)) { - item.Role = reader.GetString(2); + item.Role = role; } - if (!reader.IsDBNull(3)) + if (reader.TryGetString(3, out var type)) { - item.Type = reader.GetString(3); + item.Type = type; } - if (!reader.IsDBNull(4)) + if (reader.TryGetInt32(4, out var sortOrder)) { - item.SortOrder = reader.GetInt32(4); + item.SortOrder = sortOrder; } return item; @@ -5884,7 +5896,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { var itemIdBlob = id.ToByteArray(); @@ -5990,13 +6003,12 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } } - /// <summary> /// Gets the chapter. /// </summary> /// <param name="reader">The reader.</param> /// <returns>ChapterInfo.</returns> - private MediaStream GetMediaStream(IReadOnlyList<IResultSetValue> reader) + private MediaStream GetMediaStream(IReadOnlyList<ResultSetValue> reader) { var item = new MediaStream { @@ -6005,157 +6017,157 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type item.Type = Enum.Parse<MediaStreamType>(reader[2].ToString(), true); - if (reader[3].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(3, out var codec)) { - item.Codec = reader[3].ToString(); + item.Codec = codec; } - if (reader[4].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(4, out var language)) { - item.Language = reader[4].ToString(); + item.Language = language; } - if (reader[5].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(5, out var channelLayout)) { - item.ChannelLayout = reader[5].ToString(); + item.ChannelLayout = channelLayout; } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(6, out var profile)) { - item.Profile = reader[6].ToString(); + item.Profile = profile; } - if (reader[7].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(7, out var aspectRatio)) { - item.AspectRatio = reader[7].ToString(); + item.AspectRatio = aspectRatio; } - if (reader[8].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(8, out var path)) { - item.Path = RestorePath(reader[8].ToString()); + item.Path = RestorePath(path); } item.IsInterlaced = reader.GetBoolean(9); - if (reader[10].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(10, out var bitrate)) { - item.BitRate = reader.GetInt32(10); + item.BitRate = bitrate; } - if (reader[11].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(11, out var channels)) { - item.Channels = reader.GetInt32(11); + item.Channels = channels; } - if (reader[12].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(12, out var sampleRate)) { - item.SampleRate = reader.GetInt32(12); + item.SampleRate = sampleRate; } item.IsDefault = reader.GetBoolean(13); item.IsForced = reader.GetBoolean(14); item.IsExternal = reader.GetBoolean(15); - if (reader[16].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(16, out var width)) { - item.Width = reader.GetInt32(16); + item.Width = width; } - if (reader[17].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(17, out var height)) { - item.Height = reader.GetInt32(17); + item.Height = height; } - if (reader[18].SQLiteType != SQLiteType.Null) + if (reader.TryGetSingle(18, out var averageFrameRate)) { - item.AverageFrameRate = reader.GetFloat(18); + item.AverageFrameRate = averageFrameRate; } - if (reader[19].SQLiteType != SQLiteType.Null) + if (reader.TryGetSingle(19, out var realFrameRate)) { - item.RealFrameRate = reader.GetFloat(19); + item.RealFrameRate = realFrameRate; } - if (reader[20].SQLiteType != SQLiteType.Null) + if (reader.TryGetSingle(20, out var level)) { - item.Level = reader.GetFloat(20); + item.Level = level; } - if (reader[21].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(21, out var pixelFormat)) { - item.PixelFormat = reader[21].ToString(); + item.PixelFormat = pixelFormat; } - if (reader[22].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(22, out var bitDepth)) { - item.BitDepth = reader.GetInt32(22); + item.BitDepth = bitDepth; } - if (reader[23].SQLiteType != SQLiteType.Null) + if (reader.TryGetBoolean(23, out var isAnamorphic)) { - item.IsAnamorphic = reader.GetBoolean(23); + item.IsAnamorphic = isAnamorphic; } - if (reader[24].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(24, out var refFrames)) { - item.RefFrames = reader.GetInt32(24); + item.RefFrames = refFrames; } - if (reader[25].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(25, out var codecTag)) { - item.CodecTag = reader.GetString(25); + item.CodecTag = codecTag; } - if (reader[26].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(26, out var comment)) { - item.Comment = reader.GetString(26); + item.Comment = comment; } - if (reader[27].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(27, out var nalLengthSize)) { - item.NalLengthSize = reader.GetString(27); + item.NalLengthSize = nalLengthSize; } - if (reader[28].SQLiteType != SQLiteType.Null) + if (reader.TryGetBoolean(28, out var isAVC)) { - item.IsAVC = reader[28].ToBool(); + item.IsAVC = isAVC; } - if (reader[29].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(29, out var title)) { - item.Title = reader[29].ToString(); + item.Title = title; } - if (reader[30].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(30, out var timeBase)) { - item.TimeBase = reader[30].ToString(); + item.TimeBase = timeBase; } - if (reader[31].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(31, out var codecTimeBase)) { - item.CodecTimeBase = reader[31].ToString(); + item.CodecTimeBase = codecTimeBase; } - if (reader[32].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(32, out var colorPrimaries)) { - item.ColorPrimaries = reader[32].ToString(); + item.ColorPrimaries = colorPrimaries; } - if (reader[33].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(33, out var colorSpace)) { - item.ColorSpace = reader[33].ToString(); + item.ColorSpace = colorSpace; } - if (reader[34].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(34, out var colorTransfer)) { - item.ColorTransfer = reader[34].ToString(); + item.ColorTransfer = colorTransfer; } if (item.Type == MediaStreamType.Subtitle) { - item.localizedUndefined = _localization.GetLocalizedString("Undefined"); - item.localizedDefault = _localization.GetLocalizedString("Default"); - item.localizedForced = _localization.GetLocalizedString("Forced"); + item.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); + item.LocalizedDefault = _localization.GetLocalizedString("Default"); + item.LocalizedForced = _localization.GetLocalizedString("Forced"); } return item; @@ -6207,7 +6219,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); if (id == Guid.Empty) { - throw new ArgumentException(nameof(id)); + throw new ArgumentException("Guid can't be empty.", nameof(id)); } if (attachments == null) @@ -6219,7 +6231,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { var itemIdBlob = id.ToByteArray(); @@ -6296,36 +6309,36 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type /// </summary> /// <param name="reader">The reader.</param> /// <returns>MediaAttachment.</returns> - private MediaAttachment GetMediaAttachment(IReadOnlyList<IResultSetValue> reader) + private MediaAttachment GetMediaAttachment(IReadOnlyList<ResultSetValue> reader) { var item = new MediaAttachment { Index = reader[1].ToInt() }; - if (reader[2].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(2, out var codec)) { - item.Codec = reader[2].ToString(); + item.Codec = codec; } - if (reader[2].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(3, out var codecTag)) { - item.CodecTag = reader[3].ToString(); + item.CodecTag = codecTag; } - if (reader[4].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(4, out var comment)) { - item.Comment = reader[4].ToString(); + item.Comment = comment; } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(5, out var fileName)) { - item.FileName = reader[5].ToString(); + item.FileName = fileName; } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(6, out var mimeType)) { - item.MimeType = reader[6].ToString(); + item.MimeType = mimeType; } return item; diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index 4a78aac8e..ef9af1dcd 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -44,9 +46,10 @@ namespace Emby.Server.Implementations.Data var users = userDatasTableExists ? null : userManager.Users; - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { - db.ExecuteAll(string.Join(";", new[] { + db.ExecuteAll(string.Join(';', new[] { "create table if not exists UserDatas (key nvarchar not null, userId INT not null, rating float null, played bit not null, playCount int not null, isFavorite bit not null, playbackPositionTicks bigint not null, lastPlayedDate datetime null, AudioStreamIndex INT, SubtitleStreamIndex INT)", @@ -178,7 +181,8 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { SaveUserData(db, internalUserId, key, userData); }, TransactionMode); @@ -246,7 +250,8 @@ namespace Emby.Server.Implementations.Data using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { foreach (var userItemData in userDataList) { @@ -345,16 +350,16 @@ namespace Emby.Server.Implementations.Data /// Read a row from the specified reader into the provided userData object. /// </summary> /// <param name="reader"></param> - private UserItemData ReadRow(IReadOnlyList<IResultSetValue> reader) + private UserItemData ReadRow(IReadOnlyList<ResultSetValue> reader) { var userData = new UserItemData(); userData.Key = reader[0].ToString(); // userData.UserId = reader[1].ReadGuidFromBlob(); - if (reader[2].SQLiteType != SQLiteType.Null) + if (reader.TryGetDouble(2, out var rating)) { - userData.Rating = reader[2].ToDouble(); + userData.Rating = rating; } userData.Played = reader[3].ToBool(); @@ -362,19 +367,19 @@ namespace Emby.Server.Implementations.Data userData.IsFavorite = reader[5].ToBool(); userData.PlaybackPositionTicks = reader[6].ToInt64(); - if (reader[7].SQLiteType != SQLiteType.Null) + if (reader.TryReadDateTime(7, out var lastPlayedDate)) { - userData.LastPlayedDate = reader[7].TryReadDateTime(); + userData.LastPlayedDate = lastPlayedDate; } - if (reader[8].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(8, out var audioStreamIndex)) { - userData.AudioStreamIndex = reader[8].ToInt(); + userData.AudioStreamIndex = audioStreamIndex; } - if (reader[9].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(9, out var subtitleStreamIndex)) { - userData.SubtitleStreamIndex = reader[9].ToInt(); + userData.SubtitleStreamIndex = subtitleStreamIndex; } return userData; diff --git a/Emby.Server.Implementations/Data/TypeMapper.cs b/Emby.Server.Implementations/Data/TypeMapper.cs index 7044b1d19..064664e1f 100644 --- a/Emby.Server.Implementations/Data/TypeMapper.cs +++ b/Emby.Server.Implementations/Data/TypeMapper.cs @@ -13,7 +13,7 @@ namespace Emby.Server.Implementations.Data /// This holds all the types in the running assemblies /// so that we can de-serialize properly when we don't have strong types. /// </summary> - private readonly ConcurrentDictionary<string, Type> _typeMap = new ConcurrentDictionary<string, Type>(); + private readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>(); /// <summary> /// Gets the type. @@ -21,26 +21,16 @@ namespace Emby.Server.Implementations.Data /// <param name="typeName">Name of the type.</param> /// <returns>Type.</returns> /// <exception cref="ArgumentNullException"><c>typeName</c> is null.</exception> - public Type GetType(string typeName) + public Type? GetType(string typeName) { if (string.IsNullOrEmpty(typeName)) { throw new ArgumentNullException(nameof(typeName)); } - return _typeMap.GetOrAdd(typeName, LookupType); - } - - /// <summary> - /// Lookups the type. - /// </summary> - /// <param name="typeName">Name of the type.</param> - /// <returns>Type.</returns> - private Type LookupType(string typeName) - { - return AppDomain.CurrentDomain.GetAssemblies() - .Select(a => a.GetType(typeName)) - .FirstOrDefault(t => t != null); + return _typeMap.GetOrAdd(typeName, k => AppDomain.CurrentDomain.GetAssemblies() + .Select(a => a.GetType(k)) + .FirstOrDefault(t => t != null)); } } } diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs index fa6ac95fd..3d15b3e76 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs index f98c694c4..2637addce 100644 --- a/Emby.Server.Implementations/Devices/DeviceManager.cs +++ b/Emby.Server.Implementations/Devices/DeviceManager.cs @@ -1,61 +1,40 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Devices; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Session; -using Microsoft.Extensions.Caching.Memory; namespace Emby.Server.Implementations.Devices { public class DeviceManager : IDeviceManager { - private readonly IMemoryCache _memoryCache; - private readonly IJsonSerializer _json; private readonly IUserManager _userManager; - private readonly IServerConfigurationManager _config; private readonly IAuthenticationRepository _authRepo; - private readonly object _capabilitiesSyncLock = new object(); - - public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; + private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new (); - public DeviceManager( - IAuthenticationRepository authRepo, - IJsonSerializer json, - IUserManager userManager, - IServerConfigurationManager config, - IMemoryCache memoryCache) + public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager) { - _json = json; _userManager = userManager; - _config = config; - _memoryCache = memoryCache; _authRepo = authRepo; } + public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; + public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) { - var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json"); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - lock (_capabilitiesSyncLock) - { - _memoryCache.Set(deviceId, capabilities); - _json.SerializeToFile(capabilities, path); - } + _capabilitiesMap[deviceId] = capabilities; } public void UpdateDeviceOptions(string deviceId, DeviceOptions options) @@ -72,33 +51,13 @@ namespace Emby.Server.Implementations.Devices public ClientCapabilities GetCapabilities(string id) { - if (_memoryCache.TryGetValue(id, out ClientCapabilities result)) - { - return result; - } - - lock (_capabilitiesSyncLock) - { - var path = Path.Combine(GetDevicePath(id), "capabilities.json"); - try - { - return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities(); - } - catch - { - } - } - - return new ClientCapabilities(); + return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result) + ? result + : new ClientCapabilities(); } public DeviceInfo GetDevice(string id) { - return GetDevice(id, true); - } - - private DeviceInfo GetDevice(string id, bool includeCapabilities) - { var session = _authRepo.Get(new AuthenticationInfoQuery { DeviceId = id @@ -154,16 +113,6 @@ namespace Emby.Server.Implementations.Devices }; } - private string GetDevicesPath() - { - return Path.Combine(_config.ApplicationPaths.DataPath, "devices"); - } - - private string GetDevicePath(string id) - { - return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N", CultureInfo.InvariantCulture)); - } - public bool CanAccessDevice(User user, string deviceId) { if (user == null) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index f2c7118fe..7411239a1 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -197,7 +199,7 @@ namespace Emby.Server.Implementations.Dto catch (Exception ex) { // Have to use a catch-all unfortunately because some .net image methods throw plain Exceptions - _logger.LogError(ex, "Error generating PrimaryImageAspectRatio for {itemName}", item.Name); + _logger.LogError(ex, "Error generating PrimaryImageAspectRatio for {ItemName}", item.Name); } } @@ -249,7 +251,7 @@ namespace Emby.Server.Implementations.Dto var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path); if (activeRecording != null) { - dto.Type = "Recording"; + dto.Type = BaseItemKind.Recording; dto.CanDownload = false; dto.RunTimeTicks = null; @@ -275,7 +277,7 @@ namespace Emby.Server.Implementations.Dto continue; } - var containers = container.Split(new[] { ',' }); + var containers = container.Split(','); if (containers.Length < 2) { continue; @@ -465,10 +467,9 @@ namespace Emby.Server.Implementations.Dto { var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = item.Album, Limit = 1 - }); if (parentAlbumIds.Count > 0) @@ -583,7 +584,26 @@ namespace Emby.Server.Implementations.Dto { baseItemPerson.PrimaryImageTag = GetTagAndFillBlurhash(dto, entity, ImageType.Primary); baseItemPerson.Id = entity.Id.ToString("N", CultureInfo.InvariantCulture); - baseItemPerson.ImageBlurHashes = dto.ImageBlurHashes; + if (dto.ImageBlurHashes != null) + { + // Only add BlurHash for the person's image. + baseItemPerson.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>(); + foreach (var (imageType, blurHash) in dto.ImageBlurHashes) + { + if (blurHash != null) + { + baseItemPerson.ImageBlurHashes[imageType] = new Dictionary<string, string>(); + foreach (var (imageId, blurHashValue) in blurHash) + { + if (string.Equals(baseItemPerson.PrimaryImageTag, imageId, StringComparison.OrdinalIgnoreCase)) + { + baseItemPerson.ImageBlurHashes[imageType][imageId] = blurHashValue; + } + } + } + } + } + list.Add(baseItemPerson); } } @@ -647,10 +667,7 @@ namespace Emby.Server.Implementations.Dto var tag = GetImageCacheTag(item, image); if (!string.IsNullOrEmpty(image.BlurHash)) { - if (dto.ImageBlurHashes == null) - { - dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>(); - } + dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>(); if (!dto.ImageBlurHashes.ContainsKey(image.Type)) { @@ -684,10 +701,7 @@ namespace Emby.Server.Implementations.Dto if (hashes.Count > 0) { - if (dto.ImageBlurHashes == null) - { - dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>(); - } + dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>(); dto.ImageBlurHashes[imageType] = hashes; } @@ -880,13 +894,10 @@ namespace Emby.Server.Implementations.Dto dto.Taglines = new string[] { item.Tagline }; } - if (dto.Taglines == null) - { - dto.Taglines = Array.Empty<string>(); - } + dto.Taglines ??= Array.Empty<string>(); } - dto.Type = item.GetClientTypeName(); + dto.Type = item.GetBaseItemKind(); if ((item.CommunityRating ?? 0) > 0) { dto.CommunityRating = item.CommunityRating; @@ -1139,6 +1150,10 @@ namespace Emby.Server.Implementations.Dto if (episodeSeries != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary); + if (dto.ImageTags == null || !dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, episodeSeries); + } } } @@ -1185,6 +1200,10 @@ namespace Emby.Server.Implementations.Dto if (series != null) { dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary); + if (dto.ImageTags == null || !dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, series); + } } } } @@ -1431,7 +1450,7 @@ namespace Emby.Server.Implementations.Dto return null; } - return width / height; + return (double)width / height; } } } diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 60564f700..e0f841d52 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -9,6 +9,7 @@ <ProjectReference Include="..\Emby.Naming\Emby.Naming.csproj" /> <ProjectReference Include="..\Emby.Notifications\Emby.Notifications.csproj" /> <ProjectReference Include="..\Jellyfin.Api\Jellyfin.Api.csproj" /> + <ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" /> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> @@ -22,26 +23,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="IPNetwork2" Version="2.5.211" /> <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="3.1.6" /> - <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.6" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.6" /> - <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.6" /> - <PackageReference Include="Mono.Nat" Version="2.0.2" /> - <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.3.1" /> - <PackageReference Include="ServiceStack.Text.Core" Version="5.9.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" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> + <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="Microsoft.EntityFrameworkCore.Relational" Version="5.0.9" /> + <PackageReference Include="Mono.Nat" Version="3.0.1" /> + <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.1.0" /> + <PackageReference Include="sharpcompress" Version="0.28.3" /> + <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> + <PackageReference Include="DotNet.Glob" Version="3.1.2" /> </ItemGroup> <ItemGroup> @@ -49,29 +41,29 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors> + <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> + <NoWarn>AD0001</NoWarn> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Release'"> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> </ItemGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> <EmbeddedResource Include="Localization\iso6392.txt" /> <EmbeddedResource Include="Localization\countries.json" /> <EmbeddedResource Include="Localization\Core\*.json" /> <EmbeddedResource Include="Localization\Ratings\*.csv" /> </ItemGroup> - </Project> diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs index 2e8cc76d2..0a4efd73c 100644 --- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs +++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -8,6 +10,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; +using Jellyfin.Networking.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Plugins; @@ -56,7 +59,7 @@ namespace Emby.Server.Implementations.EntryPoints private string GetConfigIdentifier() { const char Separator = '|'; - var config = _config.Configuration; + var config = _config.GetNetworkConfiguration(); return new StringBuilder(32) .Append(config.EnableUPnP).Append(Separator) @@ -93,7 +96,8 @@ namespace Emby.Server.Implementations.EntryPoints private void Start() { - if (!_config.Configuration.EnableUPnP || !_config.Configuration.EnableRemoteAccess) + var config = _config.GetNetworkConfiguration(); + if (!config.EnableUPnP || !config.EnableRemoteAccess) { return; } @@ -104,8 +108,6 @@ namespace Emby.Server.Implementations.EntryPoints NatUtility.StartDiscovery(); _timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); - - _deviceDiscovery.DeviceDiscovered += OnDeviceDiscoveryDeviceDiscovered; } private void Stop() @@ -116,13 +118,6 @@ namespace Emby.Server.Implementations.EntryPoints NatUtility.DeviceFound -= OnNatUtilityDeviceFound; _timer?.Dispose(); - - _deviceDiscovery.DeviceDiscovered -= OnDeviceDiscoveryDeviceDiscovered; - } - - private void OnDeviceDiscoveryDeviceDiscovered(object sender, GenericEventArgs<UpnpDeviceInfo> e) - { - NatUtility.Search(e.Argument.LocalIpAddress, NatProtocol.Upnp); } private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) @@ -156,11 +151,12 @@ namespace Emby.Server.Implementations.EntryPoints private IEnumerable<Task> CreatePortMaps(INatDevice device) { - yield return CreatePortMap(device, _appHost.HttpPort, _config.Configuration.PublicPort); + var config = _config.GetNetworkConfiguration(); + yield return CreatePortMap(device, _appHost.HttpPort, config.PublicPort); if (_appHost.ListenWithHttps) { - yield return CreatePortMap(device, _appHost.HttpsPort, _config.Configuration.PublicHttpsPort); + yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort); } } diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index c9d21d963..5bb4100ba 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -1,6 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -16,6 +19,7 @@ using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints @@ -43,7 +47,7 @@ namespace Emby.Server.Implementations.EntryPoints private readonly List<BaseItem> _itemsAdded = new List<BaseItem>(); private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>(); private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>(); - private readonly Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>(); + private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new ConcurrentDictionary<Guid, DateTime>(); public LibraryChangedNotifier( ILibraryManager libraryManager, @@ -97,7 +101,7 @@ namespace Emby.Server.Implementations.EntryPoints } } - _lastProgressMessageTimes[item.Id] = DateTime.UtcNow; + _lastProgressMessageTimes.AddOrUpdate(item.Id, key => DateTime.UtcNow, (key, existing) => DateTime.UtcNow); var dict = new Dictionary<string, string>(); dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture); @@ -105,7 +109,7 @@ namespace Emby.Server.Implementations.EntryPoints try { - _sessionManager.SendMessageToAdminSessions("RefreshProgress", dict, CancellationToken.None); + _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, dict, CancellationToken.None); } catch { @@ -123,7 +127,7 @@ namespace Emby.Server.Implementations.EntryPoints try { - _sessionManager.SendMessageToAdminSessions("RefreshProgress", collectionFolderDict, CancellationToken.None); + _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, collectionFolderDict, CancellationToken.None); } catch { @@ -139,6 +143,8 @@ namespace Emby.Server.Implementations.EntryPoints private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e) { OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100))); + + _lastProgressMessageTimes.TryRemove(e.Argument.Id, out DateTime removed); } private static bool EnableRefreshMessage(BaseItem item) @@ -345,7 +351,7 @@ namespace Emby.Server.Implementations.EntryPoints try { - await _sessionManager.SendMessageToUserSessions(new List<Guid> { userId }, "LibraryChanged", info, cancellationToken).ConfigureAwait(false); + await _sessionManager.SendMessageToUserSessions(new List<Guid> { userId }, SessionMessageType.LibraryChanged, info, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs index 44d2580d6..e0ca02d98 100644 --- a/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,6 +12,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints @@ -46,25 +49,25 @@ namespace Emby.Server.Implementations.EntryPoints private async void OnLiveTvManagerSeriesTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) { - await SendMessage("SeriesTimerCreated", e.Argument).ConfigureAwait(false); + await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); } private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) { - await SendMessage("TimerCreated", e.Argument).ConfigureAwait(false); + await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); } private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) { - await SendMessage("SeriesTimerCancelled", e.Argument).ConfigureAwait(false); + await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); } private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) { - await SendMessage("TimerCancelled", e.Argument).ConfigureAwait(false); + await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); } - private async Task SendMessage(string name, TimerEventInfo info) + private async Task SendMessage(SessionMessageType name, TimerEventInfo info) { var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList(); diff --git a/Emby.Server.Implementations/EntryPoints/StartupWizard.cs b/Emby.Server.Implementations/EntryPoints/StartupWizard.cs deleted file mode 100644 index 2e738deeb..000000000 --- a/Emby.Server.Implementations/EntryPoints/StartupWizard.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Threading.Tasks; -using Emby.Server.Implementations.Browser; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Extensions; -using MediaBrowser.Controller.Plugins; -using Microsoft.Extensions.Configuration; - -namespace Emby.Server.Implementations.EntryPoints -{ - /// <summary> - /// Class StartupWizard. - /// </summary> - public sealed class StartupWizard : IServerEntryPoint - { - private readonly IServerApplicationHost _appHost; - private readonly IConfiguration _appConfig; - private readonly IServerConfigurationManager _config; - private readonly IStartupOptions _startupOptions; - - /// <summary> - /// Initializes a new instance of the <see cref="StartupWizard"/> class. - /// </summary> - /// <param name="appHost">The application host.</param> - /// <param name="appConfig">The application configuration.</param> - /// <param name="config">The configuration manager.</param> - /// <param name="startupOptions">The application startup options.</param> - public StartupWizard( - IServerApplicationHost appHost, - IConfiguration appConfig, - IServerConfigurationManager config, - IStartupOptions startupOptions) - { - _appHost = appHost; - _appConfig = appConfig; - _config = config; - _startupOptions = startupOptions; - } - - /// <inheritdoc /> - public Task RunAsync() - { - Run(); - return Task.CompletedTask; - } - - private void Run() - { - if (!_appHost.CanLaunchWebBrowser) - { - return; - } - - // Always launch the startup wizard if possible when it has not been completed - if (!_config.Configuration.IsStartupWizardCompleted && _appConfig.HostWebClient()) - { - BrowserLauncher.OpenWebApp(_appHost); - return; - } - - // Do nothing if the web app is configured to not run automatically - if (!_config.Configuration.AutoRunWebApp || _startupOptions.NoAutoRunWebApp) - { - return; - } - - // Launch the swagger page if the web client is not hosted, otherwise open the web client - if (_appConfig.HostWebClient()) - { - BrowserLauncher.OpenWebApp(_appHost); - } - else - { - BrowserLauncher.OpenSwaggerPage(_appHost); - } - } - - /// <inheritdoc /> - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs index 9486874d5..2e72b18f5 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -1,3 +1,4 @@ +using System; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -29,7 +30,7 @@ namespace Emby.Server.Implementations.EntryPoints /// <summary> /// The UDP server. /// </summary> - private UdpServer _udpServer; + private UdpServer? _udpServer; private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); private bool _disposed = false; @@ -49,10 +50,12 @@ namespace Emby.Server.Implementations.EntryPoints /// <inheritdoc /> public Task RunAsync() { + CheckDisposed(); + try { - _udpServer = new UdpServer(_logger, _appHost, _config); - _udpServer.Start(PortNumber, _cancellationTokenSource.Token); + _udpServer = new UdpServer(_logger, _appHost, _config, PortNumber); + _udpServer.Start(_cancellationTokenSource.Token); } catch (SocketException ex) { @@ -62,6 +65,14 @@ namespace Emby.Server.Implementations.EntryPoints return Task.CompletedTask; } + private void CheckDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(this.GetType().Name); + } + } + /// <inheritdoc /> public void Dispose() { @@ -71,9 +82,8 @@ namespace Emby.Server.Implementations.EntryPoints } _cancellationTokenSource.Cancel(); - _udpServer.Dispose(); _cancellationTokenSource.Dispose(); - _cancellationTokenSource = null; + _udpServer?.Dispose(); _udpServer = null; _disposed = true; diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index 3618b88c5..d3bcd5e13 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -26,8 +26,7 @@ namespace Emby.Server.Implementations.EntryPoints private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new Dictionary<Guid, List<BaseItem>>(); private readonly object _syncLock = new object(); - private Timer _updateTimer; - + private Timer? _updateTimer; public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, IUserManager userManager) { @@ -43,7 +42,7 @@ namespace Emby.Server.Implementations.EntryPoints return Task.CompletedTask; } - void OnUserDataManagerUserDataSaved(object sender, UserDataSaveEventArgs e) + private void OnUserDataManagerUserDataSaved(object? sender, UserDataSaveEventArgs e) { if (e.SaveReason == UserDataSaveReason.PlaybackProgress) { @@ -65,7 +64,7 @@ namespace Emby.Server.Implementations.EntryPoints _updateTimer.Change(UpdateDuration, Timeout.Infinite); } - if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem> keys)) + if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys)) { keys = new List<BaseItem>(); _changedItems[e.UserId] = keys; @@ -88,7 +87,7 @@ namespace Emby.Server.Implementations.EntryPoints } } - private void UpdateTimerCallback(object state) + private void UpdateTimerCallback(object? state) { lock (_syncLock) { @@ -116,7 +115,7 @@ namespace Emby.Server.Implementations.EntryPoints private Task SendNotifications(Guid userId, List<BaseItem> changedItems, CancellationToken cancellationToken) { - return _sessionManager.SendMessageToUserSessions(new List<Guid> { userId }, "UserDataChanged", () => GetUserDataChangeInfo(userId, changedItems), cancellationToken); + return _sessionManager.SendMessageToUserSessions(new List<Guid> { userId }, SessionMessageType.UserDataChanged, () => GetUserDataChangeInfo(userId, changedItems), cancellationToken); } private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems) diff --git a/Emby.Server.Implementations/HttpClientManager/HttpClientManager.cs b/Emby.Server.Implementations/HttpClientManager/HttpClientManager.cs deleted file mode 100644 index 25adc5812..000000000 --- a/Emby.Server.Implementations/HttpClientManager/HttpClientManager.cs +++ /dev/null @@ -1,335 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace Emby.Server.Implementations.HttpClientManager -{ - /// <summary> - /// Class HttpClientManager. - /// </summary> - public class HttpClientManager : IHttpClient - { - private readonly ILogger<HttpClientManager> _logger; - private readonly IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private readonly IApplicationHost _appHost; - - /// <summary> - /// Holds a dictionary of http clients by host. Use GetHttpClient(host) to retrieve or create a client for web requests. - /// DON'T dispose it after use. - /// </summary> - /// <value>The HTTP clients.</value> - private readonly ConcurrentDictionary<string, HttpClient> _httpClients = new ConcurrentDictionary<string, HttpClient>(); - - /// <summary> - /// Initializes a new instance of the <see cref="HttpClientManager" /> class. - /// </summary> - public HttpClientManager( - IApplicationPaths appPaths, - ILogger<HttpClientManager> logger, - IFileSystem fileSystem, - IApplicationHost appHost) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _fileSystem = fileSystem; - _appPaths = appPaths ?? throw new ArgumentNullException(nameof(appPaths)); - _appHost = appHost; - } - - /// <summary> - /// Gets the correct http client for the given url. - /// </summary> - /// <param name="url">The url.</param> - /// <returns>HttpClient.</returns> - private HttpClient GetHttpClient(string url) - { - var key = GetHostFromUrl(url); - - if (!_httpClients.TryGetValue(key, out var client)) - { - client = new HttpClient() - { - BaseAddress = new Uri(url) - }; - - _httpClients.TryAdd(key, client); - } - - return client; - } - - private HttpRequestMessage GetRequestMessage(HttpRequestOptions options, HttpMethod method) - { - string url = options.Url; - var uriAddress = new Uri(url); - string userInfo = uriAddress.UserInfo; - if (!string.IsNullOrWhiteSpace(userInfo)) - { - _logger.LogWarning("Found userInfo in url: {0} ... url: {1}", userInfo, url); - url = url.Replace(userInfo + '@', string.Empty, StringComparison.Ordinal); - } - - var request = new HttpRequestMessage(method, url); - - foreach (var header in options.RequestHeaders) - { - request.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - - if (options.EnableDefaultUserAgent - && !request.Headers.TryGetValues(HeaderNames.UserAgent, out _)) - { - request.Headers.Add(HeaderNames.UserAgent, _appHost.ApplicationUserAgent); - } - - switch (options.DecompressionMethod) - { - case CompressionMethods.Deflate | CompressionMethods.Gzip: - request.Headers.Add(HeaderNames.AcceptEncoding, new[] { "gzip", "deflate" }); - break; - case CompressionMethods.Deflate: - request.Headers.Add(HeaderNames.AcceptEncoding, "deflate"); - break; - case CompressionMethods.Gzip: - request.Headers.Add(HeaderNames.AcceptEncoding, "gzip"); - break; - default: - break; - } - - if (options.EnableKeepAlive) - { - request.Headers.Add(HeaderNames.Connection, "Keep-Alive"); - } - - // request.Headers.Add(HeaderNames.CacheControl, "no-cache"); - - /* - if (!string.IsNullOrWhiteSpace(userInfo)) - { - var parts = userInfo.Split(':'); - if (parts.Length == 2) - { - request.Headers.Add(HeaderNames., GetCredential(url, parts[0], parts[1]); - } - } - */ - - return request; - } - - /// <summary> - /// Gets the response internal. - /// </summary> - /// <param name="options">The options.</param> - /// <returns>Task{HttpResponseInfo}.</returns> - public Task<HttpResponseInfo> GetResponse(HttpRequestOptions options) - => SendAsync(options, HttpMethod.Get); - - /// <summary> - /// Performs a GET request and returns the resulting stream. - /// </summary> - /// <param name="options">The options.</param> - /// <returns>Task{Stream}.</returns> - public async Task<Stream> Get(HttpRequestOptions options) - { - var response = await GetResponse(options).ConfigureAwait(false); - return response.Content; - } - - /// <summary> - /// send as an asynchronous operation. - /// </summary> - /// <param name="options">The options.</param> - /// <param name="httpMethod">The HTTP method.</param> - /// <returns>Task{HttpResponseInfo}.</returns> - public Task<HttpResponseInfo> SendAsync(HttpRequestOptions options, string httpMethod) - => SendAsync(options, new HttpMethod(httpMethod)); - - /// <summary> - /// send as an asynchronous operation. - /// </summary> - /// <param name="options">The options.</param> - /// <param name="httpMethod">The HTTP method.</param> - /// <returns>Task{HttpResponseInfo}.</returns> - public async Task<HttpResponseInfo> SendAsync(HttpRequestOptions options, HttpMethod httpMethod) - { - if (options.CacheMode == CacheMode.None) - { - return await SendAsyncInternal(options, httpMethod).ConfigureAwait(false); - } - - var url = options.Url; - var urlHash = url.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); - - var responseCachePath = Path.Combine(_appPaths.CachePath, "httpclient", urlHash); - - var response = GetCachedResponse(responseCachePath, options.CacheLength, url); - if (response != null) - { - return response; - } - - response = await SendAsyncInternal(options, httpMethod).ConfigureAwait(false); - - if (response.StatusCode == HttpStatusCode.OK) - { - await CacheResponse(response, responseCachePath).ConfigureAwait(false); - } - - return response; - } - - private HttpResponseInfo GetCachedResponse(string responseCachePath, TimeSpan cacheLength, string url) - { - if (File.Exists(responseCachePath) - && _fileSystem.GetLastWriteTimeUtc(responseCachePath).Add(cacheLength) > DateTime.UtcNow) - { - var stream = new FileStream(responseCachePath, FileMode.Open, FileAccess.Read, FileShare.Read, IODefaults.FileStreamBufferSize, true); - - return new HttpResponseInfo - { - ResponseUrl = url, - Content = stream, - StatusCode = HttpStatusCode.OK, - ContentLength = stream.Length - }; - } - - return null; - } - - private async Task CacheResponse(HttpResponseInfo response, string responseCachePath) - { - Directory.CreateDirectory(Path.GetDirectoryName(responseCachePath)); - - using (var fileStream = new FileStream( - responseCachePath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - IODefaults.FileStreamBufferSize, - true)) - { - await response.Content.CopyToAsync(fileStream).ConfigureAwait(false); - - response.Content.Position = 0; - } - } - - private async Task<HttpResponseInfo> SendAsyncInternal(HttpRequestOptions options, HttpMethod httpMethod) - { - ValidateParams(options); - - options.CancellationToken.ThrowIfCancellationRequested(); - - var client = GetHttpClient(options.Url); - - var httpWebRequest = GetRequestMessage(options, httpMethod); - - if (!string.IsNullOrEmpty(options.RequestContent) - || httpMethod == HttpMethod.Post) - { - if (options.RequestContent != null) - { - httpWebRequest.Content = new StringContent( - options.RequestContent, - null, - options.RequestContentType); - } - else - { - httpWebRequest.Content = new ByteArrayContent(Array.Empty<byte>()); - } - } - - options.CancellationToken.ThrowIfCancellationRequested(); - - var response = await client.SendAsync( - httpWebRequest, - options.BufferContent || options.CacheMode == CacheMode.Unconditional ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead, - options.CancellationToken).ConfigureAwait(false); - - await EnsureSuccessStatusCode(response, options).ConfigureAwait(false); - - options.CancellationToken.ThrowIfCancellationRequested(); - - var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - return new HttpResponseInfo(response.Headers, response.Content.Headers) - { - Content = stream, - StatusCode = response.StatusCode, - ContentType = response.Content.Headers.ContentType?.MediaType, - ContentLength = response.Content.Headers.ContentLength, - ResponseUrl = response.Content.Headers.ContentLocation?.ToString() - }; - } - - /// <inheritdoc /> - public Task<HttpResponseInfo> Post(HttpRequestOptions options) - => SendAsync(options, HttpMethod.Post); - - private void ValidateParams(HttpRequestOptions options) - { - if (string.IsNullOrEmpty(options.Url)) - { - throw new ArgumentNullException(nameof(options)); - } - } - - /// <summary> - /// Gets the host from URL. - /// </summary> - /// <param name="url">The URL.</param> - /// <returns>System.String.</returns> - private static string GetHostFromUrl(string url) - { - var index = url.IndexOf("://", StringComparison.OrdinalIgnoreCase); - - if (index != -1) - { - url = url.Substring(index + 3); - var host = url.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); - - if (!string.IsNullOrWhiteSpace(host)) - { - return host; - } - } - - return url; - } - - private async Task EnsureSuccessStatusCode(HttpResponseMessage response, HttpRequestOptions options) - { - if (response.IsSuccessStatusCode) - { - return; - } - - if (options.LogErrorResponseBody) - { - string msg = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - _logger.LogError("HTTP request failed with message: {Message}", msg); - } - - throw new HttpException(response.ReasonPhrase) - { - StatusCode = response.StatusCode - }; - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/FileWriter.cs b/Emby.Server.Implementations/HttpServer/FileWriter.cs deleted file mode 100644 index 6fce8de44..000000000 --- a/Emby.Server.Implementations/HttpServer/FileWriter.cs +++ /dev/null @@ -1,250 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace Emby.Server.Implementations.HttpServer -{ - public class FileWriter : IHttpResult - { - private static readonly CultureInfo UsCulture = CultureInfo.ReadOnly(new CultureInfo("en-US")); - - private static readonly string[] _skipLogExtensions = { - ".js", - ".html", - ".css" - }; - - private readonly IStreamHelper _streamHelper; - private readonly ILogger _logger; - - /// <summary> - /// The _options. - /// </summary> - private readonly IDictionary<string, string> _options = new Dictionary<string, string>(); - - /// <summary> - /// The _requested ranges. - /// </summary> - private List<KeyValuePair<long, long?>> _requestedRanges; - - public FileWriter(string path, string contentType, string rangeHeader, ILogger logger, IFileSystem fileSystem, IStreamHelper streamHelper) - { - if (string.IsNullOrEmpty(contentType)) - { - throw new ArgumentNullException(nameof(contentType)); - } - - _streamHelper = streamHelper; - - Path = path; - _logger = logger; - RangeHeader = rangeHeader; - - Headers[HeaderNames.ContentType] = contentType; - - TotalContentLength = fileSystem.GetFileInfo(path).Length; - Headers[HeaderNames.AcceptRanges] = "bytes"; - - if (string.IsNullOrWhiteSpace(rangeHeader)) - { - Headers[HeaderNames.ContentLength] = TotalContentLength.ToString(CultureInfo.InvariantCulture); - StatusCode = HttpStatusCode.OK; - } - else - { - StatusCode = HttpStatusCode.PartialContent; - SetRangeValues(); - } - - FileShare = FileShare.Read; - Cookies = new List<Cookie>(); - } - - private string RangeHeader { get; set; } - - private bool IsHeadRequest { get; set; } - - private long RangeStart { get; set; } - - private long RangeEnd { get; set; } - - private long RangeLength { get; set; } - - public long TotalContentLength { get; set; } - - public Action OnComplete { get; set; } - - public Action OnError { get; set; } - - public List<Cookie> Cookies { get; private set; } - - public FileShare FileShare { get; set; } - - /// <summary> - /// Gets the options. - /// </summary> - /// <value>The options.</value> - public IDictionary<string, string> Headers => _options; - - public string Path { get; set; } - - /// <summary> - /// Gets the requested ranges. - /// </summary> - /// <value>The requested ranges.</value> - protected List<KeyValuePair<long, long?>> RequestedRanges - { - get - { - if (_requestedRanges == null) - { - _requestedRanges = new List<KeyValuePair<long, long?>>(); - - // Example: bytes=0-,32-63 - var ranges = RangeHeader.Split('=')[1].Split(','); - - foreach (var range in ranges) - { - var vals = range.Split('-'); - - long start = 0; - long? end = null; - - if (!string.IsNullOrEmpty(vals[0])) - { - start = long.Parse(vals[0], UsCulture); - } - - if (!string.IsNullOrEmpty(vals[1])) - { - end = long.Parse(vals[1], UsCulture); - } - - _requestedRanges.Add(new KeyValuePair<long, long?>(start, end)); - } - } - - return _requestedRanges; - } - } - - public string ContentType { get; set; } - - public IRequest RequestContext { get; set; } - - public object Response { get; set; } - - public int Status { get; set; } - - public HttpStatusCode StatusCode - { - get => (HttpStatusCode)Status; - set => Status = (int)value; - } - - /// <summary> - /// Sets the range values. - /// </summary> - private void SetRangeValues() - { - var requestedRange = RequestedRanges[0]; - - // If the requested range is "0-", we can optimize by just doing a stream copy - if (!requestedRange.Value.HasValue) - { - RangeEnd = TotalContentLength - 1; - } - else - { - RangeEnd = requestedRange.Value.Value; - } - - RangeStart = requestedRange.Key; - RangeLength = 1 + RangeEnd - RangeStart; - - // Content-Length is the length of what we're serving, not the original content - var lengthString = RangeLength.ToString(CultureInfo.InvariantCulture); - Headers[HeaderNames.ContentLength] = lengthString; - var rangeString = $"bytes {RangeStart}-{RangeEnd}/{TotalContentLength}"; - Headers[HeaderNames.ContentRange] = rangeString; - - _logger.LogDebug("Setting range response values for {0}. RangeRequest: {1} Content-Length: {2}, Content-Range: {3}", Path, RangeHeader, lengthString, rangeString); - } - - public async Task WriteToAsync(HttpResponse response, CancellationToken cancellationToken) - { - try - { - // Headers only - if (IsHeadRequest) - { - return; - } - - var path = Path; - var offset = RangeStart; - var count = RangeLength; - - if (string.IsNullOrWhiteSpace(RangeHeader) || RangeStart <= 0 && RangeEnd >= TotalContentLength - 1) - { - var extension = System.IO.Path.GetExtension(path); - - if (extension == null || !_skipLogExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) - { - _logger.LogDebug("Transmit file {0}", path); - } - - offset = 0; - count = 0; - } - - await TransmitFile(response.Body, path, offset, count, FileShare, cancellationToken).ConfigureAwait(false); - } - finally - { - OnComplete?.Invoke(); - } - } - - public async Task TransmitFile(Stream stream, string path, long offset, long count, FileShare fileShare, CancellationToken cancellationToken) - { - var fileOptions = FileOptions.SequentialScan; - - // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - fileOptions |= FileOptions.Asynchronous; - } - - using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, fileOptions)) - { - if (offset > 0) - { - fs.Position = offset; - } - - if (count > 0) - { - await _streamHelper.CopyToAsync(fs, stream, count, cancellationToken).ConfigureAwait(false); - } - else - { - await fs.CopyToAsync(stream, IODefaults.CopyToBufferSize, cancellationToken).ConfigureAwait(false); - } - } - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs deleted file mode 100644 index fe39bb4b2..000000000 --- a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs +++ /dev/null @@ -1,766 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net.Sockets; -using System.Net.WebSockets; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.Services; -using Emby.Server.Implementations.SocketSharp; -using Jellyfin.Data.Events; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; -using ServiceStack.Text.Jsv; - -namespace Emby.Server.Implementations.HttpServer -{ - public class HttpListenerHost : IHttpServer - { - /// <summary> - /// The key for a setting that specifies the default redirect path - /// to use for requests where the URL base prefix is invalid or missing. - /// </summary> - public const string DefaultRedirectKey = "HttpListenerHost:DefaultRedirectPath"; - - private readonly ILogger<HttpListenerHost> _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly IServerConfigurationManager _config; - private readonly INetworkManager _networkManager; - private readonly IServerApplicationHost _appHost; - private readonly IJsonSerializer _jsonSerializer; - private readonly IXmlSerializer _xmlSerializer; - private readonly Func<Type, Func<string, object>> _funcParseFn; - private readonly string _defaultRedirectPath; - private readonly string _baseUrlPrefix; - - private readonly Dictionary<Type, Type> _serviceOperationsMap = new Dictionary<Type, Type>(); - private readonly IHostEnvironment _hostEnvironment; - - private IWebSocketListener[] _webSocketListeners = Array.Empty<IWebSocketListener>(); - private bool _disposed = false; - - public HttpListenerHost( - IServerApplicationHost applicationHost, - ILogger<HttpListenerHost> logger, - IServerConfigurationManager config, - IConfiguration configuration, - INetworkManager networkManager, - IJsonSerializer jsonSerializer, - IXmlSerializer xmlSerializer, - ILocalizationManager localizationManager, - ServiceController serviceController, - IHostEnvironment hostEnvironment, - ILoggerFactory loggerFactory) - { - _appHost = applicationHost; - _logger = logger; - _config = config; - _defaultRedirectPath = configuration[DefaultRedirectKey]; - _baseUrlPrefix = _config.Configuration.BaseUrl; - _networkManager = networkManager; - _jsonSerializer = jsonSerializer; - _xmlSerializer = xmlSerializer; - ServiceController = serviceController; - _hostEnvironment = hostEnvironment; - _loggerFactory = loggerFactory; - - _funcParseFn = t => s => JsvReader.GetParseFn(t)(s); - - Instance = this; - ResponseFilters = Array.Empty<Action<IRequest, HttpResponse, object>>(); - GlobalResponse = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading"); - } - - public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected; - - public Action<IRequest, HttpResponse, object>[] ResponseFilters { get; set; } - - public static HttpListenerHost Instance { get; protected set; } - - public string[] UrlPrefixes { get; private set; } - - public string GlobalResponse { get; set; } - - public ServiceController ServiceController { get; } - - public object CreateInstance(Type type) - { - return _appHost.CreateInstance(type); - } - - private static string NormalizeUrlPath(string path) - { - if (path.Length > 0 && path[0] == '/') - { - // If the path begins with a leading slash, just return it as-is - return path; - } - else - { - // If the path does not begin with a leading slash, append one for consistency - return "/" + path; - } - } - - /// <summary> - /// Applies the request filters. Returns whether or not the request has been handled - /// and no more processing should be done. - /// </summary> - /// <returns></returns> - public void ApplyRequestFilters(IRequest req, HttpResponse res, object requestDto) - { - // Exec all RequestFilter attributes with Priority < 0 - var attributes = GetRequestFilterAttributes(requestDto.GetType()); - - int count = attributes.Count; - int i = 0; - for (; i < count && attributes[i].Priority < 0; i++) - { - var attribute = attributes[i]; - attribute.RequestFilter(req, res, requestDto); - } - - // Exec remaining RequestFilter attributes with Priority >= 0 - for (; i < count && attributes[i].Priority >= 0; i++) - { - var attribute = attributes[i]; - attribute.RequestFilter(req, res, requestDto); - } - } - - public Type GetServiceTypeByRequest(Type requestType) - { - _serviceOperationsMap.TryGetValue(requestType, out var serviceType); - return serviceType; - } - - public void AddServiceInfo(Type serviceType, Type requestType) - { - _serviceOperationsMap[requestType] = serviceType; - } - - private List<IHasRequestFilter> GetRequestFilterAttributes(Type requestDtoType) - { - var attributes = requestDtoType.GetCustomAttributes(true).OfType<IHasRequestFilter>().ToList(); - - var serviceType = GetServiceTypeByRequest(requestDtoType); - if (serviceType != null) - { - attributes.AddRange(serviceType.GetCustomAttributes(true).OfType<IHasRequestFilter>()); - } - - attributes.Sort((x, y) => x.Priority - y.Priority); - - return attributes; - } - - private static Exception GetActualException(Exception ex) - { - if (ex is AggregateException agg) - { - var inner = agg.InnerException; - if (inner != null) - { - return GetActualException(inner); - } - else - { - var inners = agg.InnerExceptions; - if (inners.Count > 0) - { - return GetActualException(inners[0]); - } - } - } - - return ex; - } - - private int GetStatusCode(Exception ex) - { - switch (ex) - { - case ArgumentException _: return 400; - case AuthenticationException _: return 401; - case SecurityException _: return 403; - case DirectoryNotFoundException _: - case FileNotFoundException _: - case ResourceNotFoundException _: return 404; - case MethodNotAllowedException _: return 405; - default: return 500; - } - } - - private async Task ErrorHandler(Exception ex, IRequest httpReq, int statusCode, string urlToLog, bool ignoreStackTrace) - { - if (ignoreStackTrace) - { - _logger.LogError("Error processing request: {Message}. URL: {Url}", ex.Message.TrimEnd('.'), urlToLog); - } - else - { - _logger.LogError(ex, "Error processing request. URL: {Url}", urlToLog); - } - - var httpRes = httpReq.Response; - - if (httpRes.HasStarted) - { - return; - } - - httpRes.StatusCode = statusCode; - - var errContent = _hostEnvironment.IsDevelopment() - ? (NormalizeExceptionMessage(ex) ?? string.Empty) - : "Error processing request."; - httpRes.ContentType = "text/plain"; - httpRes.ContentLength = errContent.Length; - await httpRes.WriteAsync(errContent).ConfigureAwait(false); - } - - private string NormalizeExceptionMessage(Exception ex) - { - // Do not expose the exception message for AuthenticationException - if (ex is AuthenticationException) - { - return null; - } - - // Strip any information we don't want to reveal - return ex.Message - ?.Replace(_config.ApplicationPaths.ProgramSystemPath, string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace(_config.ApplicationPaths.ProgramDataPath, string.Empty, StringComparison.OrdinalIgnoreCase); - } - - public static string RemoveQueryStringByKey(string url, string key) - { - var uri = new Uri(url); - - // this gets all the query string key value pairs as a collection - var newQueryString = QueryHelpers.ParseQuery(uri.Query); - - var originalCount = newQueryString.Count; - - if (originalCount == 0) - { - return url; - } - - // this removes the key if exists - newQueryString.Remove(key); - - if (originalCount == newQueryString.Count) - { - return url; - } - - // this gets the page path from root without QueryString - string pagePathWithoutQueryString = url.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries)[0]; - - return newQueryString.Count > 0 - ? QueryHelpers.AddQueryString(pagePathWithoutQueryString, newQueryString.ToDictionary(kv => kv.Key, kv => kv.Value.ToString())) - : pagePathWithoutQueryString; - } - - private static string GetUrlToLog(string url) - { - url = RemoveQueryStringByKey(url, "api_key"); - - return url; - } - - private static string NormalizeConfiguredLocalAddress(string address) - { - var add = address.AsSpan().Trim('/'); - int index = add.IndexOf('/'); - if (index != -1) - { - add = add.Slice(index + 1); - } - - return add.TrimStart('/').ToString(); - } - - private bool ValidateHost(string host) - { - var hosts = _config - .Configuration - .LocalNetworkAddresses - .Select(NormalizeConfiguredLocalAddress) - .ToList(); - - if (hosts.Count == 0) - { - return true; - } - - host ??= string.Empty; - - if (_networkManager.IsInPrivateAddressSpace(host)) - { - hosts.Add("localhost"); - hosts.Add("127.0.0.1"); - - return hosts.Any(i => host.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1); - } - - return true; - } - - private bool ValidateRequest(string remoteIp, bool isLocal) - { - if (isLocal) - { - return true; - } - - if (_config.Configuration.EnableRemoteAccess) - { - var addressFilter = _config.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); - - if (addressFilter.Length > 0 && !_networkManager.IsInLocalNetwork(remoteIp)) - { - if (_config.Configuration.IsRemoteIPFilterBlacklist) - { - return !_networkManager.IsAddressInSubnets(remoteIp, addressFilter); - } - else - { - return _networkManager.IsAddressInSubnets(remoteIp, addressFilter); - } - } - } - else - { - if (!_networkManager.IsInLocalNetwork(remoteIp)) - { - return false; - } - } - - return true; - } - - /// <summary> - /// Validate a connection from a remote IP address to a URL to see if a redirection to HTTPS is required. - /// </summary> - /// <returns>True if the request is valid, or false if the request is not valid and an HTTPS redirect is required.</returns> - private bool ValidateSsl(string remoteIp, string urlString) - { - if (_config.Configuration.RequireHttps - && _appHost.ListenWithHttps - && !urlString.Contains("https://", StringComparison.OrdinalIgnoreCase)) - { - // These are hacks, but if these ever occur on ipv6 in the local network they could be incorrectly redirected - if (urlString.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) != -1 - || urlString.IndexOf("dlna/", StringComparison.OrdinalIgnoreCase) != -1) - { - return true; - } - - if (!_networkManager.IsInLocalNetwork(remoteIp)) - { - return false; - } - } - - return true; - } - - /// <inheritdoc /> - public Task RequestHandler(HttpContext context) - { - if (context.WebSockets.IsWebSocketRequest) - { - return WebSocketRequestHandler(context); - } - - var request = context.Request; - var response = context.Response; - var localPath = context.Request.Path.ToString(); - - var req = new WebSocketSharpRequest(request, response, request.Path); - return RequestHandler(req, request.GetDisplayUrl(), request.Host.ToString(), localPath, context.RequestAborted); - } - - /// <summary> - /// Overridable method that can be used to implement a custom handler. - /// </summary> - private async Task RequestHandler(IHttpRequest httpReq, string urlString, string host, string localPath, CancellationToken cancellationToken) - { - var stopWatch = new Stopwatch(); - stopWatch.Start(); - var httpRes = httpReq.Response; - string urlToLog = GetUrlToLog(urlString); - string remoteIp = httpReq.RemoteIp; - - try - { - if (_disposed) - { - httpRes.StatusCode = 503; - httpRes.ContentType = "text/plain"; - await httpRes.WriteAsync("Server shutting down", cancellationToken).ConfigureAwait(false); - return; - } - - if (!ValidateHost(host)) - { - httpRes.StatusCode = 400; - httpRes.ContentType = "text/plain"; - await httpRes.WriteAsync("Invalid host", cancellationToken).ConfigureAwait(false); - return; - } - - if (!ValidateRequest(remoteIp, httpReq.IsLocal)) - { - httpRes.StatusCode = 403; - httpRes.ContentType = "text/plain"; - await httpRes.WriteAsync("Forbidden", cancellationToken).ConfigureAwait(false); - return; - } - - if (!ValidateSsl(httpReq.RemoteIp, urlString)) - { - RedirectToSecureUrl(httpReq, httpRes, urlString); - return; - } - - if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase)) - { - httpRes.StatusCode = 200; - foreach (var (key, value) in GetDefaultCorsHeaders(httpReq)) - { - httpRes.Headers.Add(key, value); - } - - httpRes.ContentType = "text/plain"; - await httpRes.WriteAsync(string.Empty, cancellationToken).ConfigureAwait(false); - return; - } - - if (string.Equals(localPath, _baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, _baseUrlPrefix, StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase) - || string.IsNullOrEmpty(localPath) - || !localPath.StartsWith(_baseUrlPrefix, StringComparison.OrdinalIgnoreCase)) - { - // Always redirect back to the default path if the base prefix is invalid or missing - _logger.LogDebug("Normalizing a URL at {0}", localPath); - httpRes.Redirect(_baseUrlPrefix + "/" + _defaultRedirectPath); - return; - } - - if (!string.IsNullOrEmpty(GlobalResponse)) - { - // We don't want the address pings in ApplicationHost to fail - if (localPath.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) == -1) - { - httpRes.StatusCode = 503; - httpRes.ContentType = "text/html"; - await httpRes.WriteAsync(GlobalResponse, cancellationToken).ConfigureAwait(false); - return; - } - } - - var handler = GetServiceHandler(httpReq); - if (handler != null) - { - await handler.ProcessRequestAsync(this, httpReq, httpRes, cancellationToken).ConfigureAwait(false); - } - else - { - throw new FileNotFoundException(); - } - } - catch (Exception requestEx) - { - try - { - var requestInnerEx = GetActualException(requestEx); - var statusCode = GetStatusCode(requestInnerEx); - - foreach (var (key, value) in GetDefaultCorsHeaders(httpReq)) - { - if (!httpRes.Headers.ContainsKey(key)) - { - httpRes.Headers.Add(key, value); - } - } - - bool ignoreStackTrace = - requestInnerEx is SocketException - || requestInnerEx is IOException - || requestInnerEx is OperationCanceledException - || requestInnerEx is SecurityException - || requestInnerEx is AuthenticationException - || requestInnerEx is FileNotFoundException; - - // Do not handle 500 server exceptions manually when in development mode. - // Instead, re-throw the exception so it can be handled by the DeveloperExceptionPageMiddleware. - // However, do not use the DeveloperExceptionPageMiddleware when the stack trace should be ignored, - // because it will log the stack trace when it handles the exception. - if (statusCode == 500 && !ignoreStackTrace && _hostEnvironment.IsDevelopment()) - { - throw; - } - - await ErrorHandler(requestInnerEx, httpReq, statusCode, urlToLog, ignoreStackTrace).ConfigureAwait(false); - } - catch (Exception handlerException) - { - var aggregateEx = new AggregateException("Error while handling request exception", requestEx, handlerException); - _logger.LogError(aggregateEx, "Error while handling exception in response to {Url}", urlToLog); - - if (_hostEnvironment.IsDevelopment()) - { - throw aggregateEx; - } - } - } - finally - { - if (httpRes.StatusCode >= 500) - { - _logger.LogDebug("Sending HTTP Response 500 in response to {Url}", urlToLog); - } - - stopWatch.Stop(); - var elapsed = stopWatch.Elapsed; - if (elapsed.TotalMilliseconds > 500) - { - _logger.LogWarning("HTTP Response {StatusCode} to {RemoteIp}. Time (slow): {Elapsed:g}. {Url}", httpRes.StatusCode, remoteIp, elapsed, urlToLog); - } - } - } - - private async Task WebSocketRequestHandler(HttpContext context) - { - if (_disposed) - { - return; - } - - try - { - _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); - - WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); - - using var connection = new WebSocketConnection( - _loggerFactory.CreateLogger<WebSocketConnection>(), - webSocket, - context.Connection.RemoteIpAddress, - context.Request.Query) - { - OnReceive = ProcessWebSocketMessageReceived - }; - - WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection)); - - await connection.ProcessAsync().ConfigureAwait(false); - _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress); - } - catch (Exception ex) // Otherwise ASP.Net will ignore the exception - { - _logger.LogError(ex, "WS {IP} WebSocketRequestHandler error", context.Connection.RemoteIpAddress); - if (!context.Response.HasStarted) - { - context.Response.StatusCode = 500; - } - } - } - - /// <summary> - /// Get the default CORS headers. - /// </summary> - /// <param name="req"></param> - /// <returns></returns> - public IDictionary<string, string> GetDefaultCorsHeaders(IRequest req) - { - var origin = req.Headers["Origin"]; - if (origin == StringValues.Empty) - { - origin = req.Headers["Host"]; - if (origin == StringValues.Empty) - { - origin = "*"; - } - } - - var headers = new Dictionary<string, string>(); - headers.Add("Access-Control-Allow-Origin", origin); - headers.Add("Access-Control-Allow-Credentials", "true"); - headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); - headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization, Range, X-MediaBrowser-Token, X-Emby-Authorization, Cookie"); - return headers; - } - - // Entry point for HttpListener - public ServiceHandler GetServiceHandler(IHttpRequest httpReq) - { - var pathInfo = httpReq.PathInfo; - - pathInfo = ServiceHandler.GetSanitizedPathInfo(pathInfo, out string contentType); - var restPath = ServiceController.GetRestPathForRequest(httpReq.HttpMethod, pathInfo); - if (restPath != null) - { - return new ServiceHandler(restPath, contentType); - } - - _logger.LogError("Could not find handler for {PathInfo}", pathInfo); - return null; - } - - private void RedirectToSecureUrl(IHttpRequest httpReq, HttpResponse httpRes, string url) - { - if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) - { - var builder = new UriBuilder(uri) - { - Port = _config.Configuration.PublicHttpsPort, - Scheme = "https" - }; - url = builder.Uri.ToString(); - } - - httpRes.Redirect(url); - } - - /// <summary> - /// Adds the rest handlers. - /// </summary> - /// <param name="serviceTypes">The service types to register with the <see cref="ServiceController"/>.</param> - /// <param name="listeners">The web socket listeners.</param> - /// <param name="urlPrefixes">The URL prefixes. See <see cref="UrlPrefixes"/>.</param> - public void Init(IEnumerable<Type> serviceTypes, IEnumerable<IWebSocketListener> listeners, IEnumerable<string> urlPrefixes) - { - _webSocketListeners = listeners.ToArray(); - UrlPrefixes = urlPrefixes.ToArray(); - - ServiceController.Init(this, serviceTypes); - - ResponseFilters = new Action<IRequest, HttpResponse, object>[] - { - new ResponseFilter(this, _logger).FilterResponse - }; - } - - public RouteAttribute[] GetRouteAttributes(Type requestType) - { - var routes = requestType.GetTypeInfo().GetCustomAttributes<RouteAttribute>(true).ToList(); - var clone = routes.ToList(); - - foreach (var route in clone) - { - routes.Add(new RouteAttribute(NormalizeCustomRoutePath(route.Path), route.Verbs) - { - Notes = route.Notes, - Priority = route.Priority, - Summary = route.Summary - }); - - routes.Add(new RouteAttribute(NormalizeEmbyRoutePath(route.Path), route.Verbs) - { - Notes = route.Notes, - Priority = route.Priority, - Summary = route.Summary - }); - - routes.Add(new RouteAttribute(NormalizeMediaBrowserRoutePath(route.Path), route.Verbs) - { - Notes = route.Notes, - Priority = route.Priority, - Summary = route.Summary - }); - } - - return routes.ToArray(); - } - - public Func<string, object> GetParseFn(Type propertyType) - { - return _funcParseFn(propertyType); - } - - public void SerializeToJson(object o, Stream stream) - { - _jsonSerializer.SerializeToStream(o, stream); - } - - public void SerializeToXml(object o, Stream stream) - { - _xmlSerializer.SerializeToStream(o, stream); - } - - public Task<object> DeserializeXml(Type type, Stream stream) - { - return Task.FromResult(_xmlSerializer.DeserializeFromStream(type, stream)); - } - - public Task<object> DeserializeJson(Type type, Stream stream) - { - return _jsonSerializer.DeserializeFromStreamAsync(stream, type); - } - - private string NormalizeEmbyRoutePath(string path) - { - _logger.LogDebug("Normalizing /emby route"); - return _baseUrlPrefix + "/emby" + NormalizeUrlPath(path); - } - - private string NormalizeMediaBrowserRoutePath(string path) - { - _logger.LogDebug("Normalizing /mediabrowser route"); - return _baseUrlPrefix + "/mediabrowser" + NormalizeUrlPath(path); - } - - private string NormalizeCustomRoutePath(string path) - { - _logger.LogDebug("Normalizing custom route {0}", path); - return _baseUrlPrefix + NormalizeUrlPath(path); - } - - /// <summary> - /// Processes the web socket message received. - /// </summary> - /// <param name="result">The result.</param> - private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) - { - if (_disposed) - { - return Task.CompletedTask; - } - - IEnumerable<Task> GetTasks() - { - foreach (var x in _webSocketListeners) - { - yield return x.ProcessMessageAsync(result); - } - } - - return Task.WhenAll(GetTasks()); - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs b/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs deleted file mode 100644 index 688216373..000000000 --- a/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs +++ /dev/null @@ -1,721 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Net; -using System.Runtime.Serialization; -using System.Text; -using System.Threading.Tasks; -using System.Xml; -using Emby.Server.Implementations.Services; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; -using Microsoft.Net.Http.Headers; -using IRequest = MediaBrowser.Model.Services.IRequest; -using MimeTypes = MediaBrowser.Model.Net.MimeTypes; - -namespace Emby.Server.Implementations.HttpServer -{ - /// <summary> - /// Class HttpResultFactory. - /// </summary> - public class HttpResultFactory : IHttpResultFactory - { - // Last-Modified and If-Modified-Since must follow strict date format, - // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since - private const string HttpDateFormat = "ddd, dd MMM yyyy HH:mm:ss \"GMT\""; - // We specifically use en-US culture because both day of week and month names require it - private static readonly CultureInfo _enUSculture = new CultureInfo("en-US", false); - - /// <summary> - /// The logger. - /// </summary> - private readonly ILogger<HttpResultFactory> _logger; - private readonly IFileSystem _fileSystem; - private readonly IJsonSerializer _jsonSerializer; - private readonly IStreamHelper _streamHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="HttpResultFactory" /> class. - /// </summary> - public HttpResultFactory(ILoggerFactory loggerfactory, IFileSystem fileSystem, IJsonSerializer jsonSerializer, IStreamHelper streamHelper) - { - _fileSystem = fileSystem; - _jsonSerializer = jsonSerializer; - _streamHelper = streamHelper; - _logger = loggerfactory.CreateLogger<HttpResultFactory>(); - } - - /// <summary> - /// Gets the result. - /// </summary> - /// <param name="requestContext">The request context.</param> - /// <param name="content">The content.</param> - /// <param name="contentType">Type of the content.</param> - /// <param name="responseHeaders">The response headers.</param> - /// <returns>System.Object.</returns> - public object GetResult(IRequest requestContext, byte[] content, string contentType, IDictionary<string, string> responseHeaders = null) - { - return GetHttpResult(requestContext, content, contentType, true, responseHeaders); - } - - public object GetResult(string content, string contentType, IDictionary<string, string> responseHeaders = null) - { - return GetHttpResult(null, content, contentType, true, responseHeaders); - } - - public object GetResult(IRequest requestContext, Stream content, string contentType, IDictionary<string, string> responseHeaders = null) - { - return GetHttpResult(requestContext, content, contentType, true, responseHeaders); - } - - public object GetResult(IRequest requestContext, string content, string contentType, IDictionary<string, string> responseHeaders = null) - { - return GetHttpResult(requestContext, content, contentType, true, responseHeaders); - } - - public object GetRedirectResult(string url) - { - var responseHeaders = new Dictionary<string, string>(); - responseHeaders[HeaderNames.Location] = url; - - var result = new HttpResult(Array.Empty<byte>(), "text/plain", HttpStatusCode.Redirect); - - AddResponseHeaders(result, responseHeaders); - - return result; - } - - /// <summary> - /// Gets the HTTP result. - /// </summary> - private IHasHeaders GetHttpResult(IRequest requestContext, Stream content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null) - { - var result = new StreamWriter(content, contentType); - - if (responseHeaders == null) - { - responseHeaders = new Dictionary<string, string>(); - } - - if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out _)) - { - responseHeaders[HeaderNames.Expires] = "0"; - } - - AddResponseHeaders(result, responseHeaders); - - return result; - } - - /// <summary> - /// Gets the HTTP result. - /// </summary> - private IHasHeaders GetHttpResult(IRequest requestContext, byte[] content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null) - { - string compressionType = null; - bool isHeadRequest = false; - - if (requestContext != null) - { - compressionType = GetCompressionType(requestContext, content, contentType); - isHeadRequest = string.Equals(requestContext.Verb, "head", StringComparison.OrdinalIgnoreCase); - } - - IHasHeaders result; - if (string.IsNullOrEmpty(compressionType)) - { - var contentLength = content.Length; - - if (isHeadRequest) - { - content = Array.Empty<byte>(); - } - - result = new StreamWriter(content, contentType, contentLength); - } - else - { - result = GetCompressedResult(content, compressionType, responseHeaders, isHeadRequest, contentType); - } - - if (responseHeaders == null) - { - responseHeaders = new Dictionary<string, string>(); - } - - if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out string _)) - { - responseHeaders[HeaderNames.Expires] = "0"; - } - - AddResponseHeaders(result, responseHeaders); - - return result; - } - - /// <summary> - /// Gets the HTTP result. - /// </summary> - private IHasHeaders GetHttpResult(IRequest requestContext, string content, string contentType, bool addCachePrevention, IDictionary<string, string> responseHeaders = null) - { - IHasHeaders result; - - var bytes = Encoding.UTF8.GetBytes(content); - - var compressionType = requestContext == null ? null : GetCompressionType(requestContext, bytes, contentType); - - var isHeadRequest = requestContext == null ? false : string.Equals(requestContext.Verb, "head", StringComparison.OrdinalIgnoreCase); - - if (string.IsNullOrEmpty(compressionType)) - { - var contentLength = bytes.Length; - - if (isHeadRequest) - { - bytes = Array.Empty<byte>(); - } - - result = new StreamWriter(bytes, contentType, contentLength); - } - else - { - result = GetCompressedResult(bytes, compressionType, responseHeaders, isHeadRequest, contentType); - } - - if (responseHeaders == null) - { - responseHeaders = new Dictionary<string, string>(); - } - - if (addCachePrevention && !responseHeaders.TryGetValue(HeaderNames.Expires, out string _)) - { - responseHeaders[HeaderNames.Expires] = "0"; - } - - AddResponseHeaders(result, responseHeaders); - - return result; - } - - /// <summary> - /// Gets the optimized result. - /// </summary> - /// <typeparam name="T"></typeparam> - public object GetResult<T>(IRequest requestContext, T result, IDictionary<string, string> responseHeaders = null) - where T : class - { - if (result == null) - { - throw new ArgumentNullException(nameof(result)); - } - - if (responseHeaders == null) - { - responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } - - responseHeaders[HeaderNames.Expires] = "0"; - - return ToOptimizedResultInternal(requestContext, result, responseHeaders); - } - - private string GetCompressionType(IRequest request, byte[] content, string responseContentType) - { - if (responseContentType == null) - { - return null; - } - - // Per apple docs, hls manifests must be compressed - if (!responseContentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) && - responseContentType.IndexOf("json", StringComparison.OrdinalIgnoreCase) == -1 && - responseContentType.IndexOf("javascript", StringComparison.OrdinalIgnoreCase) == -1 && - responseContentType.IndexOf("xml", StringComparison.OrdinalIgnoreCase) == -1 && - responseContentType.IndexOf("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == -1) - { - return null; - } - - if (content.Length < 1024) - { - return null; - } - - return GetCompressionType(request); - } - - private static string GetCompressionType(IRequest request) - { - var acceptEncoding = request.Headers[HeaderNames.AcceptEncoding].ToString(); - - if (!string.IsNullOrEmpty(acceptEncoding)) - { - // if (_brotliCompressor != null && acceptEncoding.IndexOf("br", StringComparison.OrdinalIgnoreCase) != -1) - // return "br"; - - if (acceptEncoding.Contains("deflate", StringComparison.OrdinalIgnoreCase)) - { - return "deflate"; - } - - if (acceptEncoding.Contains("gzip", StringComparison.OrdinalIgnoreCase)) - { - return "gzip"; - } - } - - return null; - } - - /// <summary> - /// Returns the optimized result for the IRequestContext. - /// Does not use or store results in any cache. - /// </summary> - /// <param name="request"></param> - /// <param name="dto"></param> - /// <returns></returns> - public object ToOptimizedResult<T>(IRequest request, T dto) - { - return ToOptimizedResultInternal(request, dto); - } - - private object ToOptimizedResultInternal<T>(IRequest request, T dto, IDictionary<string, string> responseHeaders = null) - { - // TODO: @bond use Span and .Equals - var contentType = request.ResponseContentType?.Split(';')[0].Trim().ToLowerInvariant(); - - switch (contentType) - { - case "application/xml": - case "text/xml": - case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml - return GetHttpResult(request, SerializeToXmlString(dto), contentType, false, responseHeaders); - - case "application/json": - case "text/json": - return GetHttpResult(request, _jsonSerializer.SerializeToString(dto), contentType, false, responseHeaders); - default: - break; - } - - var isHeadRequest = string.Equals(request.Verb, "head", StringComparison.OrdinalIgnoreCase); - - var ms = new MemoryStream(); - var writerFn = RequestHelper.GetResponseWriter(HttpListenerHost.Instance, contentType); - - writerFn(dto, ms); - - ms.Position = 0; - - if (isHeadRequest) - { - using (ms) - { - return GetHttpResult(request, Array.Empty<byte>(), contentType, true, responseHeaders); - } - } - - return GetHttpResult(request, ms, contentType, true, responseHeaders); - } - - private IHasHeaders GetCompressedResult( - byte[] content, - string requestedCompressionType, - IDictionary<string, string> responseHeaders, - bool isHeadRequest, - string contentType) - { - if (responseHeaders == null) - { - responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - } - - content = Compress(content, requestedCompressionType); - responseHeaders[HeaderNames.ContentEncoding] = requestedCompressionType; - - responseHeaders[HeaderNames.Vary] = HeaderNames.AcceptEncoding; - - var contentLength = content.Length; - - if (isHeadRequest) - { - var result = new StreamWriter(Array.Empty<byte>(), contentType, contentLength); - AddResponseHeaders(result, responseHeaders); - return result; - } - else - { - var result = new StreamWriter(content, contentType, contentLength); - AddResponseHeaders(result, responseHeaders); - return result; - } - } - - private byte[] Compress(byte[] bytes, string compressionType) - { - if (string.Equals(compressionType, "deflate", StringComparison.OrdinalIgnoreCase)) - { - return Deflate(bytes); - } - - if (string.Equals(compressionType, "gzip", StringComparison.OrdinalIgnoreCase)) - { - return GZip(bytes); - } - - throw new NotSupportedException(compressionType); - } - - private static byte[] Deflate(byte[] bytes) - { - // In .NET FX incompat-ville, you can't access compressed bytes without closing DeflateStream - // Which means we must use MemoryStream since you have to use ToArray() on a closed Stream - using (var ms = new MemoryStream()) - using (var zipStream = new DeflateStream(ms, CompressionMode.Compress)) - { - zipStream.Write(bytes, 0, bytes.Length); - zipStream.Dispose(); - - return ms.ToArray(); - } - } - - private static byte[] GZip(byte[] buffer) - { - using (var ms = new MemoryStream()) - using (var zipStream = new GZipStream(ms, CompressionMode.Compress)) - { - zipStream.Write(buffer, 0, buffer.Length); - zipStream.Dispose(); - - return ms.ToArray(); - } - } - - private static string SerializeToXmlString(object from) - { - using (var ms = new MemoryStream()) - { - var xwSettings = new XmlWriterSettings(); - xwSettings.Encoding = new UTF8Encoding(false); - xwSettings.OmitXmlDeclaration = false; - - using (var xw = XmlWriter.Create(ms, xwSettings)) - { - var serializer = new DataContractSerializer(from.GetType()); - serializer.WriteObject(xw, from); - xw.Flush(); - ms.Seek(0, SeekOrigin.Begin); - using (var reader = new StreamReader(ms)) - { - return reader.ReadToEnd(); - } - } - } - } - - /// <summary> - /// Pres the process optimized result. - /// </summary> - private object GetCachedResult(IRequest requestContext, IDictionary<string, string> responseHeaders, StaticResultOptions options) - { - bool noCache = requestContext.Headers[HeaderNames.CacheControl].ToString().IndexOf("no-cache", StringComparison.OrdinalIgnoreCase) != -1; - AddCachingHeaders(responseHeaders, options.CacheDuration, noCache, options.DateLastModified); - - if (!noCache) - { - if (!DateTime.TryParseExact(requestContext.Headers[HeaderNames.IfModifiedSince], HttpDateFormat, _enUSculture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ifModifiedSinceHeader)) - { - _logger.LogDebug("Failed to parse If-Modified-Since header date: {0}", requestContext.Headers[HeaderNames.IfModifiedSince]); - return null; - } - - if (IsNotModified(ifModifiedSinceHeader, options.CacheDuration, options.DateLastModified)) - { - AddAgeHeader(responseHeaders, options.DateLastModified); - - var result = new HttpResult(Array.Empty<byte>(), options.ContentType ?? "text/html", HttpStatusCode.NotModified); - - AddResponseHeaders(result, responseHeaders); - - return result; - } - } - - return null; - } - - public Task<object> GetStaticFileResult(IRequest requestContext, - string path, - FileShare fileShare = FileShare.Read) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - return GetStaticFileResult(requestContext, new StaticFileResultOptions - { - Path = path, - FileShare = fileShare - }); - } - - public Task<object> GetStaticFileResult(IRequest requestContext, StaticFileResultOptions options) - { - var path = options.Path; - var fileShare = options.FileShare; - - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentException("Path can't be empty.", nameof(options)); - } - - if (fileShare != FileShare.Read && fileShare != FileShare.ReadWrite) - { - throw new ArgumentException("FileShare must be either Read or ReadWrite"); - } - - if (string.IsNullOrEmpty(options.ContentType)) - { - options.ContentType = MimeTypes.GetMimeType(path); - } - - if (!options.DateLastModified.HasValue) - { - options.DateLastModified = _fileSystem.GetLastWriteTimeUtc(path); - } - - options.ContentFactory = () => Task.FromResult(GetFileStream(path, fileShare)); - - options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - return GetStaticResult(requestContext, options); - } - - /// <summary> - /// Gets the file stream. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="fileShare">The file share.</param> - /// <returns>Stream.</returns> - private Stream GetFileStream(string path, FileShare fileShare) - { - return new FileStream(path, FileMode.Open, FileAccess.Read, fileShare); - } - - public Task<object> GetStaticResult(IRequest requestContext, - Guid cacheKey, - DateTime? lastDateModified, - TimeSpan? cacheDuration, - string contentType, - Func<Task<Stream>> factoryFn, - IDictionary<string, string> responseHeaders = null, - bool isHeadRequest = false) - { - return GetStaticResult(requestContext, new StaticResultOptions - { - CacheDuration = cacheDuration, - ContentFactory = factoryFn, - ContentType = contentType, - DateLastModified = lastDateModified, - IsHeadRequest = isHeadRequest, - ResponseHeaders = responseHeaders - }); - } - - public async Task<object> GetStaticResult(IRequest requestContext, StaticResultOptions options) - { - options.ResponseHeaders = options.ResponseHeaders ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - var contentType = options.ContentType; - if (!StringValues.IsNullOrEmpty(requestContext.Headers[HeaderNames.IfModifiedSince])) - { - // See if the result is already cached in the browser - var result = GetCachedResult(requestContext, options.ResponseHeaders, options); - - if (result != null) - { - return result; - } - } - - // TODO: We don't really need the option value - var isHeadRequest = options.IsHeadRequest || string.Equals(requestContext.Verb, "HEAD", StringComparison.OrdinalIgnoreCase); - var factoryFn = options.ContentFactory; - var responseHeaders = options.ResponseHeaders; - AddCachingHeaders(responseHeaders, options.CacheDuration, false, options.DateLastModified); - AddAgeHeader(responseHeaders, options.DateLastModified); - - var rangeHeader = requestContext.Headers[HeaderNames.Range]; - - if (!isHeadRequest && !string.IsNullOrEmpty(options.Path)) - { - var hasHeaders = new FileWriter(options.Path, contentType, rangeHeader, _logger, _fileSystem, _streamHelper) - { - OnComplete = options.OnComplete, - OnError = options.OnError, - FileShare = options.FileShare - }; - - AddResponseHeaders(hasHeaders, options.ResponseHeaders); - return hasHeaders; - } - - var stream = await factoryFn().ConfigureAwait(false); - - var totalContentLength = options.ContentLength; - if (!totalContentLength.HasValue) - { - try - { - totalContentLength = stream.Length; - } - catch (NotSupportedException) - { - } - } - - if (!string.IsNullOrWhiteSpace(rangeHeader) && totalContentLength.HasValue) - { - var hasHeaders = new RangeRequestWriter(rangeHeader, totalContentLength.Value, stream, contentType, isHeadRequest) - { - OnComplete = options.OnComplete - }; - - AddResponseHeaders(hasHeaders, options.ResponseHeaders); - return hasHeaders; - } - else - { - if (totalContentLength.HasValue) - { - responseHeaders["Content-Length"] = totalContentLength.Value.ToString(CultureInfo.InvariantCulture); - } - - if (isHeadRequest) - { - using (stream) - { - return GetHttpResult(requestContext, Array.Empty<byte>(), contentType, true, responseHeaders); - } - } - - var hasHeaders = new StreamWriter(stream, contentType) - { - OnComplete = options.OnComplete, - OnError = options.OnError - }; - - AddResponseHeaders(hasHeaders, options.ResponseHeaders); - return hasHeaders; - } - } - - /// <summary> - /// Adds the caching responseHeaders. - /// </summary> - private void AddCachingHeaders( - IDictionary<string, string> responseHeaders, - TimeSpan? cacheDuration, - bool noCache, - DateTime? lastModifiedDate) - { - if (noCache) - { - responseHeaders[HeaderNames.CacheControl] = "no-cache, no-store, must-revalidate"; - responseHeaders[HeaderNames.Pragma] = "no-cache, no-store, must-revalidate"; - return; - } - - if (cacheDuration.HasValue) - { - responseHeaders[HeaderNames.CacheControl] = "public, max-age=" + cacheDuration.Value.TotalSeconds; - } - else - { - responseHeaders[HeaderNames.CacheControl] = "public"; - } - - if (lastModifiedDate.HasValue) - { - responseHeaders[HeaderNames.LastModified] = lastModifiedDate.Value.ToUniversalTime().ToString(HttpDateFormat, _enUSculture); - } - } - - /// <summary> - /// Adds the age header. - /// </summary> - /// <param name="responseHeaders">The responseHeaders.</param> - /// <param name="lastDateModified">The last date modified.</param> - private static void AddAgeHeader(IDictionary<string, string> responseHeaders, DateTime? lastDateModified) - { - if (lastDateModified.HasValue) - { - responseHeaders[HeaderNames.Age] = Convert.ToInt64((DateTime.UtcNow - lastDateModified.Value).TotalSeconds).ToString(CultureInfo.InvariantCulture); - } - } - - /// <summary> - /// Determines whether [is not modified] [the specified if modified since]. - /// </summary> - /// <param name="ifModifiedSince">If modified since.</param> - /// <param name="cacheDuration">Duration of the cache.</param> - /// <param name="dateModified">The date modified.</param> - /// <returns><c>true</c> if [is not modified] [the specified if modified since]; otherwise, <c>false</c>.</returns> - private bool IsNotModified(DateTime ifModifiedSince, TimeSpan? cacheDuration, DateTime? dateModified) - { - if (dateModified.HasValue) - { - var lastModified = NormalizeDateForComparison(dateModified.Value); - ifModifiedSince = NormalizeDateForComparison(ifModifiedSince); - - return lastModified <= ifModifiedSince; - } - - if (cacheDuration.HasValue) - { - var cacheExpirationDate = ifModifiedSince.Add(cacheDuration.Value); - - if (DateTime.UtcNow < cacheExpirationDate) - { - return true; - } - } - - return false; - } - - - /// <summary> - /// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that. - /// </summary> - /// <param name="date">The date.</param> - /// <returns>DateTime.</returns> - private static DateTime NormalizeDateForComparison(DateTime date) - { - return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind); - } - - /// <summary> - /// Adds the response headers. - /// </summary> - /// <param name="hasHeaders">The has options.</param> - /// <param name="responseHeaders">The response headers.</param> - private static void AddResponseHeaders(IHasHeaders hasHeaders, IEnumerable<KeyValuePair<string, string>> responseHeaders) - { - foreach (var item in responseHeaders) - { - hasHeaders.Headers[item.Key] = item.Value; - } - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs b/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs deleted file mode 100644 index 980c2cd3a..000000000 --- a/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs +++ /dev/null @@ -1,212 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Buffers; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Services; -using Microsoft.Net.Http.Headers; - -namespace Emby.Server.Implementations.HttpServer -{ - public class RangeRequestWriter : IAsyncStreamWriter, IHttpResult - { - private const int BufferSize = 81920; - - private readonly Dictionary<string, string> _options = new Dictionary<string, string>(); - - private List<KeyValuePair<long, long?>> _requestedRanges; - - /// <summary> - /// Initializes a new instance of the <see cref="RangeRequestWriter" /> class. - /// </summary> - /// <param name="rangeHeader">The range header.</param> - /// <param name="contentLength">The content length.</param> - /// <param name="source">The source.</param> - /// <param name="contentType">Type of the content.</param> - /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> - public RangeRequestWriter(string rangeHeader, long contentLength, Stream source, string contentType, bool isHeadRequest) - { - if (string.IsNullOrEmpty(contentType)) - { - throw new ArgumentNullException(nameof(contentType)); - } - - RangeHeader = rangeHeader; - SourceStream = source; - IsHeadRequest = isHeadRequest; - - ContentType = contentType; - Headers[HeaderNames.ContentType] = contentType; - Headers[HeaderNames.AcceptRanges] = "bytes"; - StatusCode = HttpStatusCode.PartialContent; - - SetRangeValues(contentLength); - } - - /// <summary> - /// Gets or sets the source stream. - /// </summary> - /// <value>The source stream.</value> - private Stream SourceStream { get; set; } - private string RangeHeader { get; set; } - private bool IsHeadRequest { get; set; } - - private long RangeStart { get; set; } - private long RangeEnd { get; set; } - private long RangeLength { get; set; } - private long TotalContentLength { get; set; } - - public Action OnComplete { get; set; } - - /// <summary> - /// Additional HTTP Headers - /// </summary> - /// <value>The headers.</value> - public IDictionary<string, string> Headers => _options; - - /// <summary> - /// Gets the requested ranges. - /// </summary> - /// <value>The requested ranges.</value> - protected List<KeyValuePair<long, long?>> RequestedRanges - { - get - { - if (_requestedRanges == null) - { - _requestedRanges = new List<KeyValuePair<long, long?>>(); - - // Example: bytes=0-,32-63 - var ranges = RangeHeader.Split('=')[1].Split(','); - - foreach (var range in ranges) - { - var vals = range.Split('-'); - - long start = 0; - long? end = null; - - if (!string.IsNullOrEmpty(vals[0])) - { - start = long.Parse(vals[0], CultureInfo.InvariantCulture); - } - - if (!string.IsNullOrEmpty(vals[1])) - { - end = long.Parse(vals[1], CultureInfo.InvariantCulture); - } - - _requestedRanges.Add(new KeyValuePair<long, long?>(start, end)); - } - } - - return _requestedRanges; - } - } - - public string ContentType { get; set; } - - public IRequest RequestContext { get; set; } - - public object Response { get; set; } - - public int Status { get; set; } - - public HttpStatusCode StatusCode - { - get => (HttpStatusCode)Status; - set => Status = (int)value; - } - - /// <summary> - /// Sets the range values. - /// </summary> - private void SetRangeValues(long contentLength) - { - var requestedRange = RequestedRanges[0]; - - TotalContentLength = contentLength; - - // If the requested range is "0-", we can optimize by just doing a stream copy - if (!requestedRange.Value.HasValue) - { - RangeEnd = TotalContentLength - 1; - } - else - { - RangeEnd = requestedRange.Value.Value; - } - - RangeStart = requestedRange.Key; - RangeLength = 1 + RangeEnd - RangeStart; - - Headers[HeaderNames.ContentLength] = RangeLength.ToString(CultureInfo.InvariantCulture); - Headers[HeaderNames.ContentRange] = $"bytes {RangeStart}-{RangeEnd}/{TotalContentLength}"; - - if (RangeStart > 0 && SourceStream.CanSeek) - { - SourceStream.Position = RangeStart; - } - } - - public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken) - { - try - { - // Headers only - if (IsHeadRequest) - { - return; - } - - using (var source = SourceStream) - { - // If the requested range is "0-", we can optimize by just doing a stream copy - if (RangeEnd >= TotalContentLength - 1) - { - await source.CopyToAsync(responseStream, BufferSize, cancellationToken).ConfigureAwait(false); - } - else - { - await CopyToInternalAsync(source, responseStream, RangeLength, cancellationToken).ConfigureAwait(false); - } - } - } - finally - { - OnComplete?.Invoke(); - } - } - - private static async Task CopyToInternalAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken) - { - var array = ArrayPool<byte>.Shared.Rent(BufferSize); - try - { - int bytesRead; - while ((bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false)) != 0) - { - var bytesToCopy = Math.Min(bytesRead, copyLength); - - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToCopy), cancellationToken).ConfigureAwait(false); - - copyLength -= bytesToCopy; - - if (copyLength <= 0) - { - break; - } - } - } - finally - { - ArrayPool<byte>.Shared.Return(array); - } - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/ResponseFilter.cs b/Emby.Server.Implementations/HttpServer/ResponseFilter.cs deleted file mode 100644 index a8cd2ac8f..000000000 --- a/Emby.Server.Implementations/HttpServer/ResponseFilter.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Globalization; -using System.Text; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace Emby.Server.Implementations.HttpServer -{ - /// <summary> - /// Class ResponseFilter. - /// </summary> - public class ResponseFilter - { - private readonly IHttpServer _server; - private readonly ILogger _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="ResponseFilter"/> class. - /// </summary> - /// <param name="server">The HTTP server.</param> - /// <param name="logger">The logger.</param> - public ResponseFilter(IHttpServer server, ILogger logger) - { - _server = server; - _logger = logger; - } - - /// <summary> - /// Filters the response. - /// </summary> - /// <param name="req">The req.</param> - /// <param name="res">The res.</param> - /// <param name="dto">The dto.</param> - public void FilterResponse(IRequest req, HttpResponse res, object dto) - { - foreach(var (key, value) in _server.GetDefaultCorsHeaders(req)) - { - res.Headers.Add(key, value); - } - // Try to prevent compatibility view - res.Headers["Access-Control-Allow-Headers"] = "Accept, Accept-Language, Authorization, Cache-Control, " + - "Content-Disposition, Content-Encoding, Content-Language, Content-Length, Content-MD5, Content-Range, " + - "Content-Type, Cookie, Date, Host, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, " + - "Origin, OriginToken, Pragma, Range, Slug, Transfer-Encoding, Want-Digest, X-MediaBrowser-Token, " + - "X-Emby-Authorization"; - - if (dto is Exception exception) - { - _logger.LogError(exception, "Error processing request for {RawUrl}", req.RawUrl); - - if (!string.IsNullOrEmpty(exception.Message)) - { - var error = exception.Message.Replace(Environment.NewLine, " ", StringComparison.Ordinal); - error = RemoveControlCharacters(error); - - res.Headers.Add("X-Application-Error-Code", error); - } - } - - if (dto is IHasHeaders hasHeaders) - { - if (!hasHeaders.Headers.ContainsKey(HeaderNames.Server)) - { - hasHeaders.Headers[HeaderNames.Server] = "Microsoft-NetCore/2.0, UPnP/1.0 DLNADOC/1.50"; - } - - // Content length has to be explicitly set on on HttpListenerResponse or it won't be happy - if (hasHeaders.Headers.TryGetValue(HeaderNames.ContentLength, out string contentLength) - && !string.IsNullOrEmpty(contentLength)) - { - var length = long.Parse(contentLength, CultureInfo.InvariantCulture); - - if (length > 0) - { - res.ContentLength = length; - } - } - } - } - - /// <summary> - /// Removes the control characters. - /// </summary> - /// <param name="inString">The in string.</param> - /// <returns>System.String.</returns> - public static string RemoveControlCharacters(string inString) - { - if (inString == null) - { - return null; - } - else if (inString.Length == 0) - { - return inString; - } - - var newString = new StringBuilder(inString.Length); - - foreach (var ch in inString) - { - if (!char.IsControl(ch)) - { - newString.Append(ch); - } - } - - return newString.ToString(); - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 76c1d9bac..9afabf527 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,17 +1,8 @@ #pragma warning disable CS1591 -using System; -using System.Linq; -using Emby.Server.Implementations.SocketSharp; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Services; using Microsoft.AspNetCore.Http; namespace Emby.Server.Implementations.HttpServer.Security @@ -19,228 +10,33 @@ namespace Emby.Server.Implementations.HttpServer.Security public class AuthService : IAuthService { private readonly IAuthorizationContext _authorizationContext; - private readonly ISessionManager _sessionManager; - private readonly IServerConfigurationManager _config; - private readonly INetworkManager _networkManager; public AuthService( - IAuthorizationContext authorizationContext, - IServerConfigurationManager config, - ISessionManager sessionManager, - INetworkManager networkManager) + IAuthorizationContext authorizationContext) { _authorizationContext = authorizationContext; - _config = config; - _sessionManager = sessionManager; - _networkManager = networkManager; - } - - public void Authenticate(IRequest request, IAuthenticationAttributes authAttributes) - { - ValidateUser(request, authAttributes); - } - - public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes) - { - var req = new WebSocketSharpRequest(request, null, request.Path); - var user = ValidateUser(req, authAttributes); - return user; } public AuthorizationInfo Authenticate(HttpRequest request) { var auth = _authorizationContext.GetAuthorizationInfo(request); - if (auth?.User == null) - { - return null; - } - - if (auth.User.HasPermission(PermissionKind.IsDisabled)) - { - throw new SecurityException("User account has been disabled."); - } - - return auth; - } - - private User ValidateUser(IRequest request, IAuthenticationAttributes authAttributes) - { - // This code is executed before the service - var auth = _authorizationContext.GetAuthorizationInfo(request); - - if (!IsExemptFromAuthenticationToken(authAttributes, request)) - { - ValidateSecurityToken(request, auth.Token); - } - - if (authAttributes.AllowLocalOnly && !request.IsLocal) - { - throw new SecurityException("Operation not found."); - } - - var user = auth.User; - if (user == null && auth.UserId != Guid.Empty) + if (!auth.HasToken) { - throw new AuthenticationException("User with Id " + auth.UserId + " not found"); + throw new AuthenticationException("Request does not contain a token."); } - if (user != null) + if (!auth.IsAuthenticated) { - ValidateUserAccess(user, request, authAttributes); + throw new SecurityException("Invalid token."); } - var info = GetTokenInfo(request); - - if (!IsExemptFromRoles(auth, authAttributes, request, info)) - { - var roles = authAttributes.GetRoles(); - - ValidateRoles(roles, user); - } - - if (!string.IsNullOrEmpty(auth.DeviceId) && - !string.IsNullOrEmpty(auth.Client) && - !string.IsNullOrEmpty(auth.Device)) - { - _sessionManager.LogSessionActivity( - auth.Client, - auth.Version, - auth.DeviceId, - auth.Device, - request.RemoteIp, - user); - } - - return user; - } - - private void ValidateUserAccess( - User user, - IRequest request, - IAuthenticationAttributes authAttributes) - { - if (user.HasPermission(PermissionKind.IsDisabled)) + if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false) { throw new SecurityException("User account has been disabled."); } - if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !_networkManager.IsInLocalNetwork(request.RemoteIp)) - { - throw new SecurityException("User account has been disabled."); - } - - if (!user.HasPermission(PermissionKind.IsAdministrator) - && !authAttributes.EscapeParentalControl - && !user.IsParentalScheduleAllowed()) - { - request.Response.Headers.Add("X-Application-Error-Code", "ParentalControl"); - - throw new SecurityException("This user account is not allowed access at this time."); - } - } - - private bool IsExemptFromAuthenticationToken(IAuthenticationAttributes authAttribtues, IRequest request) - { - if (!_config.Configuration.IsStartupWizardCompleted && authAttribtues.AllowBeforeStartupWizard) - { - return true; - } - - if (authAttribtues.AllowLocal && request.IsLocal) - { - return true; - } - - if (authAttribtues.AllowLocalOnly && request.IsLocal) - { - return true; - } - - if (authAttribtues.IgnoreLegacyAuth) - { - return true; - } - - return false; - } - - private bool IsExemptFromRoles(AuthorizationInfo auth, IAuthenticationAttributes authAttribtues, IRequest request, AuthenticationInfo tokenInfo) - { - if (!_config.Configuration.IsStartupWizardCompleted && authAttribtues.AllowBeforeStartupWizard) - { - return true; - } - - if (authAttribtues.AllowLocal && request.IsLocal) - { - return true; - } - - if (authAttribtues.AllowLocalOnly && request.IsLocal) - { - return true; - } - - if (string.IsNullOrEmpty(auth.Token)) - { - return true; - } - - if (tokenInfo != null && tokenInfo.UserId.Equals(Guid.Empty)) - { - return true; - } - - return false; - } - - private static void ValidateRoles(string[] roles, User user) - { - if (roles.Contains("admin", StringComparer.OrdinalIgnoreCase)) - { - if (user == null || !user.HasPermission(PermissionKind.IsAdministrator)) - { - throw new SecurityException("User does not have admin access."); - } - } - - if (roles.Contains("delete", StringComparer.OrdinalIgnoreCase)) - { - if (user == null || !user.HasPermission(PermissionKind.EnableContentDeletion)) - { - throw new SecurityException("User does not have delete access."); - } - } - - if (roles.Contains("download", StringComparer.OrdinalIgnoreCase)) - { - if (user == null || !user.HasPermission(PermissionKind.EnableContentDownloading)) - { - throw new SecurityException("User does not have download access."); - } - } - } - - private static AuthenticationInfo GetTokenInfo(IRequest request) - { - request.Items.TryGetValue("OriginalAuthenticationInfo", out var info); - return info as AuthenticationInfo; - } - - private void ValidateSecurityToken(IRequest request, string token) - { - if (string.IsNullOrEmpty(token)) - { - throw new AuthenticationException("Access token is required."); - } - - var info = GetTokenInfo(request); - - if (info == null) - { - throw new AuthenticationException("Access token is invalid or expired."); - } + return auth; } } } diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index fb93fae3e..b2625a68c 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -2,12 +2,11 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net; +using Jellyfin.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Services; using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; @@ -24,16 +23,11 @@ namespace Emby.Server.Implementations.HttpServer.Security _userManager = userManager; } - public AuthorizationInfo GetAuthorizationInfo(object requestContext) + public AuthorizationInfo GetAuthorizationInfo(HttpContext requestContext) { - return GetAuthorizationInfo((IRequest)requestContext); - } - - public AuthorizationInfo GetAuthorizationInfo(IRequest requestContext) - { - if (requestContext.Items.TryGetValue("AuthorizationInfo", out var cached)) + if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached)) { - return (AuthorizationInfo)cached; + return (AuthorizationInfo)cached!; // Cache should never contain null } return GetAuthorization(requestContext); @@ -42,8 +36,7 @@ namespace Emby.Server.Implementations.HttpServer.Security public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext) { var auth = GetAuthorizationDictionary(requestContext); - var (authInfo, _) = - GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); + var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); return authInfo; } @@ -52,31 +45,25 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private AuthorizationInfo GetAuthorization(IRequest httpReq) + private AuthorizationInfo GetAuthorization(HttpContext httpReq) { var auth = GetAuthorizationDictionary(httpReq); - var (authInfo, originalAuthInfo) = - GetAuthorizationInfoFromDictionary(auth, httpReq.Headers, httpReq.QueryString); + var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); - if (originalAuthInfo != null) - { - httpReq.Items["OriginalAuthenticationInfo"] = originalAuthInfo; - } - - httpReq.Items["AuthorizationInfo"] = authInfo; + httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; return authInfo; } - private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary( - in Dictionary<string, string> auth, + private AuthorizationInfo GetAuthorizationInfoFromDictionary( + in Dictionary<string, string>? auth, in IHeaderDictionary headers, in IQueryCollection queryString) { - string deviceId = null; - string device = null; - string client = null; - string version = null; - string token = null; + string? deviceId = null; + string? device = null; + string? client = null; + string? version = null; + string? token = null; if (auth != null) { @@ -114,88 +101,104 @@ namespace Emby.Server.Implementations.HttpServer.Security Device = device, DeviceId = deviceId, Version = version, - Token = token + Token = token, + IsAuthenticated = false, + HasToken = false }; - AuthenticationInfo originalAuthenticationInfo = null; - if (!string.IsNullOrWhiteSpace(token)) + if (string.IsNullOrWhiteSpace(token)) { - var result = _authRepo.Get(new AuthenticationInfoQuery - { - AccessToken = token - }); + // Request doesn't contain a token. + return authInfo; + } - originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; + authInfo.HasToken = true; + var result = _authRepo.Get(new AuthenticationInfoQuery + { + AccessToken = token + }); - if (originalAuthenticationInfo != null) - { - var updateToken = false; + if (result.Items.Count > 0) + { + authInfo.IsAuthenticated = true; + } - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(authInfo.Client)) - { - authInfo.Client = originalAuthenticationInfo.AppName; - } + var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) - { - authInfo.DeviceId = originalAuthenticationInfo.DeviceId; - } + if (originalAuthenticationInfo != null) + { + var updateToken = false; - // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; + // TODO: Remove these checks for IsNullOrWhiteSpace + if (string.IsNullOrWhiteSpace(authInfo.Client)) + { + authInfo.Client = originalAuthenticationInfo.AppName; + } - if (string.IsNullOrWhiteSpace(authInfo.Device)) - { - authInfo.Device = originalAuthenticationInfo.DeviceName; - } - else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.DeviceName = authInfo.Device; - } - } + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + { + authInfo.DeviceId = originalAuthenticationInfo.DeviceId; + } - if (string.IsNullOrWhiteSpace(authInfo.Version)) - { - authInfo.Version = originalAuthenticationInfo.AppVersion; - } - else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + // Temporary. TODO - allow clients to specify that the token has been shared with a casting device + var allowTokenInfoUpdate = authInfo.Client == null || !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = originalAuthenticationInfo.DeviceName; + } + else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.AppVersion = authInfo.Version; - } + updateToken = true; + originalAuthenticationInfo.DeviceName = authInfo.Device; } + } - if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + if (string.IsNullOrWhiteSpace(authInfo.Version)) + { + authInfo.Version = originalAuthenticationInfo.AppVersion; + } + else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) { - originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; updateToken = true; + originalAuthenticationInfo.AppVersion = authInfo.Version; } + } - if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) - { - authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); + if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + { + originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; + updateToken = true; + } - if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) - { - originalAuthenticationInfo.UserName = authInfo.User.Username; - updateToken = true; - } - } + if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) + { + authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); - if (updateToken) + if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) { - _authRepo.Update(originalAuthenticationInfo); + originalAuthenticationInfo.UserName = authInfo.User.Username; + updateToken = true; } + + authInfo.IsApiKey = false; + } + else + { + authInfo.IsApiKey = true; + } + + if (updateToken) + { + _authRepo.Update(originalAuthenticationInfo); } } - return (authInfo, originalAuthenticationInfo); + return authInfo; } /// <summary> @@ -203,16 +206,16 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorizationDictionary(IRequest httpReq) + private Dictionary<string, string>? GetAuthorizationDictionary(HttpContext httpReq) { - var auth = httpReq.Headers["X-Emby-Authorization"]; + var auth = httpReq.Request.Headers["X-Emby-Authorization"]; if (string.IsNullOrEmpty(auth)) { - auth = httpReq.Headers[HeaderNames.Authorization]; + auth = httpReq.Request.Headers[HeaderNames.Authorization]; } - return GetAuthorization(auth); + return GetAuthorization(auth.Count > 0 ? auth[0] : null); } /// <summary> @@ -220,7 +223,7 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="httpReq">The HTTP req.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorizationDictionary(HttpRequest httpReq) + private Dictionary<string, string>? GetAuthorizationDictionary(HttpRequest httpReq) { var auth = httpReq.Headers["X-Emby-Authorization"]; @@ -229,7 +232,7 @@ namespace Emby.Server.Implementations.HttpServer.Security auth = httpReq.Headers[HeaderNames.Authorization]; } - return GetAuthorization(auth); + return GetAuthorization(auth.Count > 0 ? auth[0] : null); } /// <summary> @@ -237,43 +240,43 @@ namespace Emby.Server.Implementations.HttpServer.Security /// </summary> /// <param name="authorizationHeader">The authorization header.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorization(string authorizationHeader) + private Dictionary<string, string>? GetAuthorization(ReadOnlySpan<char> authorizationHeader) { if (authorizationHeader == null) { return null; } - var parts = authorizationHeader.Split(new[] { ' ' }, 2); + var firstSpace = authorizationHeader.IndexOf(' '); - // There should be at least to parts - if (parts.Length != 2) + // There should be at least two parts + if (firstSpace == -1) { return null; } - var acceptedNames = new[] { "MediaBrowser", "Emby" }; + var name = authorizationHeader[..firstSpace]; - // It has to be a digest request - if (!acceptedNames.Contains(parts[0], StringComparer.OrdinalIgnoreCase)) + if (!name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase) + && !name.Equals("Emby", StringComparison.OrdinalIgnoreCase)) { return null; } - // Remove uptil the first space - authorizationHeader = parts[1]; - parts = authorizationHeader.Split(','); + authorizationHeader = authorizationHeader[(firstSpace + 1)..]; var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - foreach (var item in parts) + foreach (var item in authorizationHeader.Split(',')) { - var param = item.Trim().Split(new[] { '=' }, 2); + var trimmedItem = item.Trim(); + var firstEqualsSign = trimmedItem.IndexOf('='); - if (param.Length == 2) + if (firstEqualsSign > 0) { - var value = NormalizeValue(param[1].Trim(new[] { '"' })); - result.Add(param[0], value); + var key = trimmedItem[..firstEqualsSign].ToString(); + var value = NormalizeValue(trimmedItem[(firstEqualsSign + 1)..].Trim('"').ToString()); + result[key] = value; } } diff --git a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs index 03fcfa53d..c375f36ce 100644 --- a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs @@ -2,11 +2,11 @@ using System; using Jellyfin.Data.Entities; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Services; +using Microsoft.AspNetCore.Http; namespace Emby.Server.Implementations.HttpServer.Security { @@ -23,35 +23,29 @@ namespace Emby.Server.Implementations.HttpServer.Security _sessionManager = sessionManager; } - public SessionInfo GetSession(IRequest requestContext) + public SessionInfo GetSession(HttpContext requestContext) { var authorization = _authContext.GetAuthorizationInfo(requestContext); var user = authorization.User; - return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.RemoteIp, user); - } - - private AuthenticationInfo GetTokenInfo(IRequest request) - { - request.Items.TryGetValue("OriginalAuthenticationInfo", out var info); - return info as AuthenticationInfo; + return _sessionManager.LogSessionActivity(authorization.Client, authorization.Version, authorization.DeviceId, authorization.Device, requestContext.GetNormalizedRemoteIp().ToString(), user); } public SessionInfo GetSession(object requestContext) { - return GetSession((IRequest)requestContext); + return GetSession((HttpContext)requestContext); } - public User GetUser(IRequest requestContext) + public User? GetUser(HttpContext requestContext) { var session = GetSession(requestContext); return session == null || session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId); } - public User GetUser(object requestContext) + public User? GetUser(object requestContext) { - return GetUser((IRequest)requestContext); + return GetUser(((HttpRequest)requestContext).HttpContext); } } } diff --git a/Emby.Server.Implementations/HttpServer/StreamWriter.cs b/Emby.Server.Implementations/HttpServer/StreamWriter.cs deleted file mode 100644 index 00e3ab8fe..000000000 --- a/Emby.Server.Implementations/HttpServer/StreamWriter.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Services; -using Microsoft.Net.Http.Headers; - -namespace Emby.Server.Implementations.HttpServer -{ - /// <summary> - /// Class StreamWriter. - /// </summary> - public class StreamWriter : IAsyncStreamWriter, IHasHeaders - { - /// <summary> - /// The options. - /// </summary> - private readonly IDictionary<string, string> _options = new Dictionary<string, string>(); - - /// <summary> - /// Initializes a new instance of the <see cref="StreamWriter" /> class. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="contentType">Type of the content.</param> - public StreamWriter(Stream source, string contentType) - { - if (string.IsNullOrEmpty(contentType)) - { - throw new ArgumentNullException(nameof(contentType)); - } - - SourceStream = source; - - Headers["Content-Type"] = contentType; - - if (source.CanSeek) - { - Headers[HeaderNames.ContentLength] = source.Length.ToString(CultureInfo.InvariantCulture); - } - - Headers[HeaderNames.ContentType] = contentType; - } - - /// <summary> - /// Initializes a new instance of the <see cref="StreamWriter"/> class. - /// </summary> - /// <param name="source">The source.</param> - /// <param name="contentType">Type of the content.</param> - /// <param name="contentLength">The content length.</param> - public StreamWriter(byte[] source, string contentType, int contentLength) - { - if (string.IsNullOrEmpty(contentType)) - { - throw new ArgumentNullException(nameof(contentType)); - } - - SourceBytes = source; - - Headers[HeaderNames.ContentLength] = contentLength.ToString(CultureInfo.InvariantCulture); - Headers[HeaderNames.ContentType] = contentType; - } - - /// <summary> - /// Gets or sets the source stream. - /// </summary> - /// <value>The source stream.</value> - private Stream SourceStream { get; set; } - - private byte[] SourceBytes { get; set; } - - /// <summary> - /// Gets the options. - /// </summary> - /// <value>The options.</value> - public IDictionary<string, string> Headers => _options; - - /// <summary> - /// Fires when complete. - /// </summary> - public Action OnComplete { get; set; } - - /// <summary> - /// Fires when an error occours. - /// </summary> - public Action OnError { get; set; } - - /// <inheritdoc /> - public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken) - { - try - { - var bytes = SourceBytes; - - if (bytes != null) - { - await responseStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); - } - else - { - using (var src = SourceStream) - { - await src.CopyToAsync(responseStream, cancellationToken).ConfigureAwait(false); - } - } - } - catch - { - OnError?.Invoke(); - - throw; - } - finally - { - OnComplete?.Invoke(); - } - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 7eae4e764..5d38ea0ca 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -1,16 +1,16 @@ -#nullable enable - using System; using System.Buffers; using System.IO.Pipelines; using System.Net; using System.Net.WebSockets; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Net; +using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -54,7 +54,7 @@ namespace Emby.Server.Implementations.HttpServer RemoteEndPoint = remoteEndPoint; QueryString = query; - _jsonOptions = JsonDefaults.GetOptions(); + _jsonOptions = JsonDefaults.Options; LastActivityDate = DateTime.Now; } @@ -137,7 +137,7 @@ namespace Emby.Server.Implementations.HttpServer writer.Advance(bytesRead); // Make the data available to the PipeReader - FlushResult flushResult = await writer.FlushAsync().ConfigureAwait(false); + FlushResult flushResult = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); if (flushResult.IsCompleted) { // The PipeReader stopped reading @@ -180,32 +180,16 @@ namespace Emby.Server.Implementations.HttpServer } WebSocketMessage<object>? stub; + long bytesConsumed = 0; try { - - if (buffer.IsSingleSegment) - { - stub = JsonSerializer.Deserialize<WebSocketMessage<object>>(buffer.FirstSpan, _jsonOptions); - } - else - { - var buf = ArrayPool<byte>.Shared.Rent(Convert.ToInt32(buffer.Length)); - try - { - buffer.CopyTo(buf); - stub = JsonSerializer.Deserialize<WebSocketMessage<object>>(buf, _jsonOptions); - } - finally - { - ArrayPool<byte>.Shared.Return(buf); - } - } + stub = DeserializeWebSocketMessage(buffer, out bytesConsumed); } catch (JsonException ex) { // Tell the PipeReader how much of the buffer we have consumed reader.AdvanceTo(buffer.End); - _logger.LogError(ex, "Error processing web socket message"); + _logger.LogError(ex, "Error processing web socket message: {Data}", Encoding.UTF8.GetString(buffer)); return; } @@ -216,27 +200,34 @@ namespace Emby.Server.Implementations.HttpServer } // Tell the PipeReader how much of the buffer we have consumed - reader.AdvanceTo(buffer.End); + reader.AdvanceTo(buffer.GetPosition(bytesConsumed)); _logger.LogDebug("WS {IP} received message: {@Message}", RemoteEndPoint, stub); - var info = new WebSocketMessageInfo - { - MessageType = stub.MessageType, - Data = stub.Data?.ToString(), // Data can be null - Connection = this - }; - - if (info.MessageType.Equals("KeepAlive", StringComparison.Ordinal)) + if (stub.MessageType == SessionMessageType.KeepAlive) { await SendKeepAliveResponse().ConfigureAwait(false); } else { - await OnReceive(info).ConfigureAwait(false); + await OnReceive( + new WebSocketMessageInfo + { + MessageType = stub.MessageType, + Data = stub.Data?.ToString(), // Data can be null + Connection = this + }).ConfigureAwait(false); } } + internal WebSocketMessage<object>? DeserializeWebSocketMessage(ReadOnlySequence<byte> bytes, out long bytesConsumed) + { + var jsonReader = new Utf8JsonReader(bytes); + var ret = JsonSerializer.Deserialize<WebSocketMessage<object>>(ref jsonReader, _jsonOptions); + bytesConsumed = jsonReader.BytesConsumed; + return ret; + } + private Task SendKeepAliveResponse() { LastKeepAliveDate = DateTime.UtcNow; @@ -244,7 +235,7 @@ namespace Emby.Server.Implementations.HttpServer new WebSocketMessage<string> { MessageId = Guid.NewGuid(), - MessageType = "KeepAlive" + MessageType = SessionMessageType.KeepAlive }, CancellationToken.None); } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs new file mode 100644 index 000000000..861c0a95e --- /dev/null +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -0,0 +1,90 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.HttpServer +{ + public class WebSocketManager : IWebSocketManager + { + private readonly IWebSocketListener[] _webSocketListeners; + private readonly IAuthService _authService; + private readonly ILogger<WebSocketManager> _logger; + private readonly ILoggerFactory _loggerFactory; + + public WebSocketManager( + IAuthService authService, + IEnumerable<IWebSocketListener> webSocketListeners, + ILogger<WebSocketManager> logger, + ILoggerFactory loggerFactory) + { + _webSocketListeners = webSocketListeners.ToArray(); + _authService = authService; + _logger = logger; + _loggerFactory = loggerFactory; + } + + /// <inheritdoc /> + public async Task WebSocketRequestHandler(HttpContext context) + { + _ = _authService.Authenticate(context.Request); + try + { + _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); + + WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + + using var connection = new WebSocketConnection( + _loggerFactory.CreateLogger<WebSocketConnection>(), + webSocket, + context.Connection.RemoteIpAddress, + context.Request.Query) + { + OnReceive = ProcessWebSocketMessageReceived + }; + + var tasks = new Task[_webSocketListeners.Length]; + for (var i = 0; i < _webSocketListeners.Length; ++i) + { + tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + + await connection.ProcessAsync().ConfigureAwait(false); + _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress); + } + catch (Exception ex) // Otherwise ASP.Net will ignore the exception + { + _logger.LogError(ex, "WS {IP} WebSocketRequestHandler error", context.Connection.RemoteIpAddress); + if (!context.Response.HasStarted) + { + context.Response.StatusCode = 500; + } + } + } + + /// <summary> + /// Processes the web socket message received. + /// </summary> + /// <param name="result">The result.</param> + private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) + { + var tasks = new Task[_webSocketListeners.Length]; + for (var i = 0; i < _webSocketListeners.Length; ++i) + { + tasks[i] = _webSocketListeners[i].ProcessMessageAsync(result); + } + + return Task.WhenAll(tasks); + } + } +} diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index fe74f1de7..47a83d77c 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -149,7 +151,7 @@ namespace Emby.Server.Implementations.IO continue; } - _logger.LogInformation("{name} ({path}) will be refreshed.", item.Name, item.Path); + _logger.LogInformation("{Name} ({Path}) will be refreshed.", item.Name, item.Path); try { @@ -160,11 +162,11 @@ namespace Emby.Server.Implementations.IO // For now swallow and log. // Research item: If an IOException occurs, the item may be in a disconnected state (media unavailable) // Should we remove it from it's parent? - _logger.LogError(ex, "Error refreshing {name}", item.Name); + _logger.LogError(ex, "Error refreshing {Name}", item.Name); } catch (Exception ex) { - _logger.LogError(ex, "Error refreshing {name}", item.Name); + _logger.LogError(ex, "Error refreshing {Name}", item.Name); } } } @@ -214,6 +216,7 @@ namespace Emby.Server.Implementations.IO } } + /// <inheritdoc /> public void Dispose() { _disposed = true; diff --git a/Emby.Server.Implementations/IO/IsoManager.cs b/Emby.Server.Implementations/IO/IsoManager.cs deleted file mode 100644 index 94e92c2a6..000000000 --- a/Emby.Server.Implementations/IO/IsoManager.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.IO; - -namespace Emby.Server.Implementations.IO -{ - /// <summary> - /// Class IsoManager. - /// </summary> - public class IsoManager : IIsoManager - { - /// <summary> - /// The _mounters. - /// </summary> - private readonly List<IIsoMounter> _mounters = new List<IIsoMounter>(); - - /// <summary> - /// Mounts the specified iso path. - /// </summary> - /// <param name="isoPath">The iso path.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns><see creaf="IsoMount" />.</returns> - public Task<IIsoMount> Mount(string isoPath, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(isoPath)) - { - throw new ArgumentNullException(nameof(isoPath)); - } - - var mounter = _mounters.FirstOrDefault(i => i.CanMount(isoPath)); - - if (mounter == null) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "No mounters are able to mount {0}", - isoPath)); - } - - return mounter.Mount(isoPath, cancellationToken); - } - - /// <summary> - /// Determines whether this instance can mount the specified path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if this instance can mount the specified path; otherwise, <c>false</c>.</returns> - public bool CanMount(string path) - { - return _mounters.Any(i => i.CanMount(path)); - } - - /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="mounters">The mounters.</param> - public void AddParts(IEnumerable<IIsoMounter> mounters) - { - _mounters.AddRange(mounters); - } - } -} diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 9290dfcd0..aa80bccd7 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -88,7 +90,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) { - _logger.LogError(ex, "Error in ReportFileSystemChanged for {path}", path); + _logger.LogError(ex, "Error in ReportFileSystemChanged for {Path}", path); } } } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index ab6483bf9..7c3c7da23 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -2,16 +2,15 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Text; +using System.Runtime.InteropServices; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.Extensions.Logging; -using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Server.Implementations.IO { @@ -24,7 +23,7 @@ namespace Emby.Server.Implementations.IO private readonly List<IShortcutHandler> _shortcutHandlers = new List<IShortcutHandler>(); private readonly string _tempPath; - private readonly bool _isEnvironmentCaseInsensitive; + private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); public ManagedFileSystem( ILogger<ManagedFileSystem> logger, @@ -32,8 +31,6 @@ namespace Emby.Server.Implementations.IO { Logger = logger; _tempPath = applicationPaths.TempDirectory; - - _isEnvironmentCaseInsensitive = OperatingSystem.Id == OperatingSystemId.Windows; } public virtual void AddShortcutHandler(IShortcutHandler handler) @@ -64,7 +61,7 @@ namespace Emby.Server.Implementations.IO /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> /// <exception cref="ArgumentNullException">filename</exception> - public virtual string ResolveShortcut(string filename) + public virtual string? ResolveShortcut(string filename) { if (string.IsNullOrEmpty(filename)) { @@ -72,7 +69,7 @@ namespace Emby.Server.Implementations.IO } var extension = Path.GetExtension(filename); - var handler = _shortcutHandlers.FirstOrDefault(i => string.Equals(extension, i.Extension, StringComparison.OrdinalIgnoreCase)); + var handler = _shortcutHandlers.Find(i => string.Equals(extension, i.Extension, StringComparison.OrdinalIgnoreCase)); return handler?.Resolve(filename); } @@ -246,16 +243,23 @@ namespace Emby.Server.Implementations.IO { result.Length = fileInfo.Length; - // Issue #2354 get the size of files behind symbolic links - if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + // Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes! + if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) { - using (Stream thisFileStream = File.OpenRead(fileInfo.FullName)) + try + { + using (Stream thisFileStream = File.OpenRead(fileInfo.FullName)) + { + result.Length = thisFileStream.Length; + } + } + catch (FileNotFoundException ex) { - result.Length = thisFileStream.Length; + // Dangling symlinks cannot be detected before opening the file unfortunately... + Logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName); + result.Exists = false; } } - - result.DirectoryName = fileInfo.DirectoryName; } result.CreationTimeUtc = GetCreationTimeUtc(info); @@ -294,16 +298,37 @@ namespace Emby.Server.Implementations.IO /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> /// <exception cref="ArgumentNullException">The filename is null.</exception> - public virtual string GetValidFilename(string filename) + public string GetValidFilename(string filename) { - var builder = new StringBuilder(filename); - - foreach (var c in Path.GetInvalidFileNameChars()) + var invalid = Path.GetInvalidFileNameChars(); + var first = filename.IndexOfAny(invalid); + if (first == -1) { - builder = builder.Replace(c, ' '); + // Fast path for clean strings + return filename; } - return builder.ToString(); + return string.Create( + filename.Length, + (filename, invalid, first), + (chars, state) => + { + state.filename.AsSpan().CopyTo(chars); + + chars[state.first++] = ' '; + + var len = chars.Length; + foreach (var c in state.invalid) + { + for (int i = state.first; i < len; i++) + { + if (chars[i] == c) + { + chars[i] = ' '; + } + } + } + }); } /// <summary> @@ -376,7 +401,7 @@ namespace Emby.Server.Implementations.IO public virtual void SetHidden(string path, bool isHidden) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -398,33 +423,9 @@ namespace Emby.Server.Implementations.IO } } - public virtual void SetReadOnly(string path, bool isReadOnly) - { - if (OperatingSystem.Id != OperatingSystemId.Windows) - { - return; - } - - var info = GetExtendedFileSystemInfo(path); - - if (info.Exists && info.IsReadOnly != isReadOnly) - { - if (isReadOnly) - { - File.SetAttributes(path, File.GetAttributes(path) | FileAttributes.ReadOnly); - } - else - { - var attributes = File.GetAttributes(path); - attributes = RemoveAttribute(attributes, FileAttributes.ReadOnly); - File.SetAttributes(path, attributes); - } - } - } - public virtual void SetAttributes(string path, bool isHidden, bool isReadOnly) { - if (OperatingSystem.Id != OperatingSystemId.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -511,26 +512,9 @@ namespace Emby.Server.Implementations.IO throw new ArgumentNullException(nameof(path)); } - var separatorChar = Path.DirectorySeparatorChar; - - return path.IndexOf(parentPath.TrimEnd(separatorChar) + separatorChar, StringComparison.OrdinalIgnoreCase) != -1; - } - - public virtual bool IsRootPath(string path) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - var parent = Path.GetDirectoryName(path); - - if (!string.IsNullOrEmpty(parent)) - { - return false; - } - - return true; + return path.Contains( + Path.TrimEndingDirectorySeparator(parentPath) + Path.DirectorySeparatorChar, + _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } public virtual string NormalizePath(string path) @@ -545,7 +529,7 @@ namespace Emby.Server.Implementations.IO return path; } - return path.TrimEnd(Path.DirectorySeparatorChar); + return Path.TrimEndingDirectorySeparator(path); } public virtual bool AreEqual(string path1, string path2) @@ -560,7 +544,10 @@ namespace Emby.Server.Implementations.IO return false; } - return string.Equals(NormalizePath(path1), NormalizePath(path2), StringComparison.OrdinalIgnoreCase); + return string.Equals( + NormalizePath(path1), + NormalizePath(path2), + _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } public virtual string GetFileNameWithoutExtension(FileSystemMetadata info) @@ -606,9 +593,7 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<FileSystemMetadata> GetDirectories(string path, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - - return ToMetadata(new DirectoryInfo(path).EnumerateDirectories("*", searchOption)); + return ToMetadata(new DirectoryInfo(path).EnumerateDirectories("*", GetEnumerationOptions(recursive))); } public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, bool recursive = false) @@ -616,30 +601,30 @@ namespace Emby.Server.Implementations.IO return GetFiles(path, null, false, recursive); } - public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, IReadOnlyList<string> extensions, bool enableCaseSensitiveExtensions, bool recursive = false) + public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, IReadOnlyList<string>? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var enumerationOptions = GetEnumerationOptions(recursive); // On linux and osx the search pattern is case sensitive // If we're OK with case-sensitivity, and we're only filtering for one extension, then use the native method if ((enableCaseSensitiveExtensions || _isEnvironmentCaseInsensitive) && extensions != null && extensions.Count == 1) { - return ToMetadata(new DirectoryInfo(path).EnumerateFiles("*" + extensions[0], searchOption)); + return ToMetadata(new DirectoryInfo(path).EnumerateFiles("*" + extensions[0], enumerationOptions)); } - var files = new DirectoryInfo(path).EnumerateFiles("*", searchOption); + var files = new DirectoryInfo(path).EnumerateFiles("*", enumerationOptions); if (extensions != null && extensions.Count > 0) { files = files.Where(i => { - var ext = i.Extension; - if (ext == null) + var ext = i.Extension.AsSpan(); + if (ext.IsEmpty) { return false; } - return extensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + return extensions.Contains(ext, StringComparison.OrdinalIgnoreCase); }); } @@ -649,10 +634,9 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<FileSystemMetadata> GetFileSystemEntries(string path, bool recursive = false) { var directoryInfo = new DirectoryInfo(path); - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var enumerationOptions = GetEnumerationOptions(recursive); - return ToMetadata(directoryInfo.EnumerateDirectories("*", searchOption)) - .Concat(ToMetadata(directoryInfo.EnumerateFiles("*", searchOption))); + return ToMetadata(directoryInfo.EnumerateFileSystemInfos("*", enumerationOptions)); } private IEnumerable<FileSystemMetadata> ToMetadata(IEnumerable<FileSystemInfo> infos) @@ -662,8 +646,7 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<string> GetDirectoryPaths(string path, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - return Directory.EnumerateDirectories(path, "*", searchOption); + return Directory.EnumerateDirectories(path, "*", GetEnumerationOptions(recursive)); } public virtual IEnumerable<string> GetFilePaths(string path, bool recursive = false) @@ -671,30 +654,30 @@ namespace Emby.Server.Implementations.IO return GetFilePaths(path, null, false, recursive); } - public virtual IEnumerable<string> GetFilePaths(string path, string[] extensions, bool enableCaseSensitiveExtensions, bool recursive = false) + public virtual IEnumerable<string> GetFilePaths(string path, string[]? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var enumerationOptions = GetEnumerationOptions(recursive); // On linux and osx the search pattern is case sensitive // If we're OK with case-sensitivity, and we're only filtering for one extension, then use the native method if ((enableCaseSensitiveExtensions || _isEnvironmentCaseInsensitive) && extensions != null && extensions.Length == 1) { - return Directory.EnumerateFiles(path, "*" + extensions[0], searchOption); + return Directory.EnumerateFiles(path, "*" + extensions[0], enumerationOptions); } - var files = Directory.EnumerateFiles(path, "*", searchOption); + var files = Directory.EnumerateFiles(path, "*", enumerationOptions); if (extensions != null && extensions.Length > 0) { files = files.Where(i => { - var ext = Path.GetExtension(i); - if (ext == null) + var ext = Path.GetExtension(i.AsSpan()); + if (ext.IsEmpty) { return false; } - return extensions.Contains(ext, StringComparer.OrdinalIgnoreCase); + return extensions.Contains(ext, StringComparison.OrdinalIgnoreCase); }); } @@ -703,31 +686,18 @@ namespace Emby.Server.Implementations.IO public virtual IEnumerable<string> GetFileSystemEntryPaths(string path, bool recursive = false) { - var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - return Directory.EnumerateFileSystemEntries(path, "*", searchOption); - } - - public virtual void SetExecutable(string path) - { - if (OperatingSystem.Id == OperatingSystemId.Darwin) - { - RunProcess("chmod", "+x \"" + path + "\"", Path.GetDirectoryName(path)); - } + return Directory.EnumerateFileSystemEntries(path, "*", GetEnumerationOptions(recursive)); } - private static void RunProcess(string path, string args, string workingDirectory) + private EnumerationOptions GetEnumerationOptions(bool recursive) { - using (var process = Process.Start(new ProcessStartInfo + return new EnumerationOptions { - Arguments = args, - FileName = path, - CreateNoWindow = true, - WorkingDirectory = workingDirectory, - WindowStyle = ProcessWindowStyle.Normal - })) - { - process.WaitForExit(); - } + RecurseSubdirectories = recursive, + IgnoreInaccessible = true, + // Don't skip any files. + AttributesToSkip = 0 + }; } } } diff --git a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs index e6696b8c4..76c58d5dc 100644 --- a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs +++ b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.IO public string Extension => ".mblink"; - public string Resolve(string shortcutPath) + public string? Resolve(string shortcutPath) { if (string.IsNullOrEmpty(shortcutPath)) { diff --git a/Emby.Server.Implementations/IO/StreamHelper.cs b/Emby.Server.Implementations/IO/StreamHelper.cs index 40b397edc..e4f5f4cf0 100644 --- a/Emby.Server.Implementations/IO/StreamHelper.cs +++ b/Emby.Server.Implementations/IO/StreamHelper.cs @@ -11,9 +11,7 @@ namespace Emby.Server.Implementations.IO { public class StreamHelper : IStreamHelper { - private const int StreamCopyToBufferSize = 81920; - - public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action onStarted, CancellationToken cancellationToken) + public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action? onStarted, CancellationToken cancellationToken) { byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize); try @@ -83,37 +81,9 @@ namespace Emby.Server.Implementations.IO } } - public async Task<int> CopyToAsync(Stream source, Stream destination, CancellationToken cancellationToken) - { - byte[] buffer = ArrayPool<byte>.Shared.Rent(StreamCopyToBufferSize); - try - { - int totalBytesRead = 0; - - int bytesRead; - while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) - { - var bytesToWrite = bytesRead; - - if (bytesToWrite > 0) - { - await destination.WriteAsync(buffer, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); - - totalBytesRead += bytesRead; - } - } - - return totalBytesRead; - } - finally - { - ArrayPool<byte>.Shared.Return(buffer); - } - } - public async Task CopyToAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken) { - byte[] buffer = ArrayPool<byte>.Shared.Rent(StreamCopyToBufferSize); + byte[] buffer = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize); try { int bytesRead; diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index e7e72c686..a430b9e72 100644 --- a/Emby.Server.Implementations/IStartupOptions.cs +++ b/Emby.Server.Implementations/IStartupOptions.cs @@ -1,7 +1,5 @@ #pragma warning disable CS1591 -using System; - namespace Emby.Server.Implementations { public interface IStartupOptions @@ -9,7 +7,7 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the value of the --ffmpeg command line option. /// </summary> - string FFmpegPath { get; } + string? FFmpegPath { get; } /// <summary> /// Gets the value of the --service command line option. @@ -17,28 +15,23 @@ namespace Emby.Server.Implementations bool IsService { get; } /// <summary> - /// Gets the value of the --noautorunwebapp command line option. - /// </summary> - bool NoAutoRunWebApp { get; } - - /// <summary> /// Gets the value of the --package-name command line option. /// </summary> - string PackageName { get; } + string? PackageName { get; } /// <summary> /// Gets the value of the --restartpath command line option. /// </summary> - string RestartPath { get; } + string? RestartPath { get; } /// <summary> /// Gets the value of the --restartargs command line option. /// </summary> - string RestartArgs { get; } + string? RestartArgs { get; } /// <summary> /// Gets the value of the --published-server-url command line option. /// </summary> - Uri PublishedServerUrl { get; } + string? PublishedServerUrl { get; } } } diff --git a/Emby.Server.Implementations/Images/ArtistImageProvider.cs b/Emby.Server.Implementations/Images/ArtistImageProvider.cs index bf57382ed..e96b64595 100644 --- a/Emby.Server.Implementations/Images/ArtistImageProvider.cs +++ b/Emby.Server.Implementations/Images/ArtistImageProvider.cs @@ -2,20 +2,12 @@ using System; using System.Collections.Generic; -using System.Linq; -using Emby.Server.Implementations.Images; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { @@ -42,7 +34,7 @@ namespace Emby.Server.Implementations.Images // return _libraryManager.GetItemList(new InternalItemsQuery // { // ArtistIds = new[] { item.Id }, - // IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + // IncludeItemTypes = new[] { nameof(MusicAlbum) }, // OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, // Limit = 4, // Recursive = true, diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 57302b506..833fb0b7a 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -133,9 +135,20 @@ namespace Emby.Server.Implementations.Images protected virtual IEnumerable<string> GetStripCollageImagePaths(BaseItem primaryItem, IEnumerable<BaseItem> items) { + var useBackdrop = primaryItem is CollectionFolder; return items .Select(i => { + // Use Backdrop instead of Primary image for Library images. + if (useBackdrop) + { + var backdrop = i.GetImageInfo(ImageType.Backdrop, 0); + if (backdrop != null && backdrop.IsLocalFile) + { + return backdrop.Path; + } + } + var image = i.GetImageInfo(ImageType.Primary, 0); if (image != null && image.IsLocalFile) { @@ -180,7 +193,7 @@ namespace Emby.Server.Implementations.Images InputPaths = GetStripCollageImagePaths(primaryItem, items).ToArray() }; - if (options.InputPaths.Length == 0) + if (options.InputPaths.Count == 0) { return null; } @@ -190,7 +203,7 @@ namespace Emby.Server.Implementations.Images return null; } - ImageProcessor.CreateImageCollage(options); + ImageProcessor.CreateImageCollage(options, primaryItem.Name); return outputPath; } diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 161b4c452..ff5f26ce0 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Images/DynamicImageProvider.cs b/Emby.Server.Implementations/Images/DynamicImageProvider.cs index 462eb03a8..900b3fd9c 100644 --- a/Emby.Server.Implementations/Images/DynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/DynamicImageProvider.cs @@ -1,10 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; using System.Linq; -using Emby.Server.Implementations.Images; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index 0224ab32a..859017f86 100644 --- a/Emby.Server.Implementations/Images/FolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index 1cd4cd66b..6da431c68 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -42,7 +44,12 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name, typeof(MusicVideo).Name, typeof(Audio).Name }, + IncludeItemTypes = new[] + { + nameof(MusicAlbum), + nameof(MusicVideo), + nameof(Audio) + }, OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, Limit = 4, Recursive = true, @@ -77,7 +84,7 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(Series).Name, typeof(Movie).Name }, + IncludeItemTypes = new[] { nameof(Series), nameof(Movie) }, OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, Limit = 4, Recursive = true, diff --git a/Emby.Server.Implementations/Images/PlaylistImageProvider.cs b/Emby.Server.Implementations/Images/PlaylistImageProvider.cs index 0ce1b91e8..b8f0f0d65 100644 --- a/Emby.Server.Implementations/Images/PlaylistImageProvider.cs +++ b/Emby.Server.Implementations/Images/PlaylistImageProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System.Collections.Generic; @@ -29,9 +31,7 @@ namespace Emby.Server.Implementations.Images { var subItem = i.Item2; - var episode = subItem as Episode; - - if (episode != null) + if (subItem is Episode episode) { var series = episode.Series; if (series != null && series.HasImage(ImageType.Primary)) diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index 3380e29d4..c7d113963 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -77,7 +77,7 @@ namespace Emby.Server.Implementations.Library if (parent != null) { // Don't resolve these into audio files - if (string.Equals(Path.GetFileNameWithoutExtension(filename), BaseItem.ThemeSongFilename, StringComparison.Ordinal) + if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFilename, StringComparison.Ordinal) && _libraryManager.IsAudioFile(filename)) { return true; diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs index 236453e80..6c65b5899 100644 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index e30a67593..5384c04b3 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -1,5 +1,3 @@ -#nullable enable - using System; using System.Linq; using DotNet.Globbing; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 375f09f5b..13fb8b2fd 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,6 +8,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Audio; @@ -18,6 +21,7 @@ using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; @@ -41,13 +45,13 @@ 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; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; +using EpisodeInfo = Emby.Naming.TV.EpisodeInfo; using Genre = MediaBrowser.Controller.Entities.Genre; using Person = MediaBrowser.Controller.Entities.Person; using VideoResolver = Emby.Naming.Video.VideoResolver; @@ -175,10 +179,7 @@ namespace Emby.Server.Implementations.Library { lock (_rootFolderSyncLock) { - if (_rootFolder == null) - { - _rootFolder = CreateRootFolder(); - } + _rootFolder ??= CreateRootFolder(); } } @@ -196,33 +197,33 @@ namespace Emby.Server.Implementations.Library /// Gets or sets the postscan tasks. /// </summary> /// <value>The postscan tasks.</value> - private ILibraryPostScanTask[] PostscanTasks { get; set; } + private ILibraryPostScanTask[] PostscanTasks { get; set; } = Array.Empty<ILibraryPostScanTask>(); /// <summary> /// Gets or sets the intro providers. /// </summary> /// <value>The intro providers.</value> - private IIntroProvider[] IntroProviders { get; set; } + private IIntroProvider[] IntroProviders { get; set; } = Array.Empty<IIntroProvider>(); /// <summary> /// Gets or sets the list of entity resolution ignore rules. /// </summary> /// <value>The entity resolution ignore rules.</value> - private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } + private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } = Array.Empty<IResolverIgnoreRule>(); /// <summary> /// Gets or sets the list of currently registered entity resolvers. /// </summary> /// <value>The entity resolvers enumerable.</value> - private IItemResolver[] EntityResolvers { get; set; } + private IItemResolver[] EntityResolvers { get; set; } = Array.Empty<IItemResolver>(); - private IMultiItemResolver[] MultiItemResolvers { get; set; } + private IMultiItemResolver[] MultiItemResolvers { get; set; } = Array.Empty<IMultiItemResolver>(); /// <summary> /// Gets or sets the comparers. /// </summary> /// <value>The comparers.</value> - private IBaseItemComparer[] Comparers { get; set; } + private IBaseItemComparer[] Comparers { get; set; } = Array.Empty<IBaseItemComparer>(); public bool IsScanRunning { get; private set; } @@ -513,10 +514,11 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(type)); } - if (key.StartsWith(_configurationManager.ApplicationPaths.ProgramDataPath, StringComparison.Ordinal)) + string programDataPath = _configurationManager.ApplicationPaths.ProgramDataPath; + if (key.StartsWith(programDataPath, StringComparison.Ordinal)) { // Try to normalize paths located underneath program-data in an attempt to make them more portable - key = key.Substring(_configurationManager.ApplicationPaths.ProgramDataPath.Length) + key = key.Substring(programDataPath.Length) .TrimStart('/', '\\') .Replace('/', '\\'); } @@ -557,7 +559,6 @@ namespace Emby.Server.Implementations.Library var args = new ItemResolveArgs(_configurationManager.ApplicationPaths, directoryService) { Parent = parent, - Path = fullPath, FileInfo = fileInfo, CollectionType = collectionType, LibraryOptions = libraryOptions @@ -683,7 +684,7 @@ namespace Emby.Server.Implementations.Library foreach (var item in items) { - ResolverHelper.SetInitialItemValues(item, parent, _fileSystem, this, directoryService); + ResolverHelper.SetInitialItemValues(item, parent, this, directoryService); } items.AddRange(ResolveFileList(result.ExtraFiles, directoryService, parent, collectionType, resolvers, libraryOptions)); @@ -696,25 +697,32 @@ namespace Emby.Server.Implementations.Library } private IEnumerable<BaseItem> ResolveFileList( - IEnumerable<FileSystemMetadata> fileList, + IReadOnlyList<FileSystemMetadata> fileList, IDirectoryService directoryService, Folder parent, string collectionType, IItemResolver[] resolvers, LibraryOptions libraryOptions) { - return fileList.Select(f => + // Given that fileList is a list we can save enumerator allocations by indexing + for (var i = 0; i < fileList.Count; i++) { + var file = fileList[i]; + BaseItem result = null; try { - return ResolvePath(f, directoryService, resolvers, parent, collectionType, libraryOptions); + result = ResolvePath(file, directoryService, resolvers, parent, collectionType, libraryOptions); } catch (Exception ex) { - _logger.LogError(ex, "Error resolving path {path}", f.FullName); - return null; + _logger.LogError(ex, "Error resolving path {Path}", file.FullName); } - }).Where(i => i != null); + + if (result != null) + { + yield return result; + } + } } /// <summary> @@ -856,7 +864,21 @@ namespace Emby.Server.Implementations.Library /// <returns>Task{Person}.</returns> public Person GetPerson(string name) { - return CreateItemByName<Person>(Person.GetPath, name, new DtoOptions(true)); + var path = Person.GetPath(name); + var id = GetItemByNameId<Person>(path); + if (!(GetItemById(id) is Person item)) + { + item = new Person + { + Name = name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + Path = path + }; + } + + return item; } /// <summary> @@ -871,17 +893,17 @@ namespace Emby.Server.Implementations.Library public Guid GetStudioId(string name) { - return GetItemByNameId<Studio>(Studio.GetPath, name); + return GetItemByNameId<Studio>(Studio.GetPath(name)); } public Guid GetGenreId(string name) { - return GetItemByNameId<Genre>(Genre.GetPath, name); + return GetItemByNameId<Genre>(Genre.GetPath(name)); } public Guid GetMusicGenreId(string name) { - return GetItemByNameId<MusicGenre>(MusicGenre.GetPath, name); + return GetItemByNameId<MusicGenre>(MusicGenre.GetPath(name)); } /// <summary> @@ -943,7 +965,7 @@ namespace Emby.Server.Implementations.Library { var existing = GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(T).Name }, + IncludeItemTypes = new[] { nameof(MusicArtist) }, Name = name, DtoOptions = options }).Cast<MusicArtist>() @@ -957,13 +979,11 @@ namespace Emby.Server.Implementations.Library } } - var id = GetItemByNameId<T>(getPathFn, name); - + var path = getPathFn(name); + var id = GetItemByNameId<T>(path); var item = GetItemById(id) as T; - if (item == null) { - var path = getPathFn(name); item = new T { Name = name, @@ -979,10 +999,9 @@ namespace Emby.Server.Implementations.Library return item; } - private Guid GetItemByNameId<T>(Func<string, string> getPathFn, string name) + private Guid GetItemByNameId<T>(string path) where T : BaseItem, new() { - var path = getPathFn(name); var forceCaseInsensitiveId = _configurationManager.Configuration.EnableNormalizedItemByNameIds; return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId); } @@ -1054,17 +1073,17 @@ namespace Emby.Server.Implementations.Library // Start by just validating the children of the root, but go no further await RootFolder.ValidateChildren( new SimpleProgress<double>(), - cancellationToken, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), - recursive: false).ConfigureAwait(false); + recursive: false, + cancellationToken).ConfigureAwait(false); await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false); await GetUserRootFolder().ValidateChildren( new SimpleProgress<double>(), - cancellationToken, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), - recursive: false).ConfigureAwait(false); + recursive: false, + cancellationToken).ConfigureAwait(false); // Quickly scan CollectionFolders for changes foreach (var folder in GetUserRootFolder().Children.OfType<Folder>()) @@ -1084,7 +1103,7 @@ namespace Emby.Server.Implementations.Library innerProgress.RegisterAction(pct => progress.Report(pct * 0.96)); // Validate the entire media library - await RootFolder.ValidateChildren(innerProgress, cancellationToken, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true).ConfigureAwait(false); + await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken).ConfigureAwait(false); progress.Report(96); @@ -1151,7 +1170,7 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } - _itemRepository.UpdateInheritedValues(cancellationToken); + _itemRepository.UpdateInheritedValues(); progress.Report(100); } @@ -1228,11 +1247,20 @@ namespace Emby.Server.Implementations.Library return info; } - private string GetCollectionType(string path) + private CollectionTypeOptions? GetCollectionType(string path) { - return _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false) - .Select(Path.GetFileNameWithoutExtension) - .FirstOrDefault(i => !string.IsNullOrEmpty(i)); + var files = _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false); + foreach (var file in files) + { + // TODO: @bond use a ReadOnlySpan<char> here when Enum.TryParse supports it + // https://github.com/dotnet/runtime/issues/20008 + if (Enum.TryParse<CollectionTypeOptions>(Path.GetFileNameWithoutExtension(file), true, out var res)) + { + return res; + } + } + + return null; } /// <summary> @@ -1504,7 +1532,7 @@ namespace Emby.Server.Implementations.Library { if (query.AncestorIds.Length == 0 && query.ParentId.Equals(Guid.Empty) && - query.ChannelIds.Length == 0 && + query.ChannelIds.Count == 0 && query.TopParentIds.Length == 0 && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && @@ -1805,21 +1833,18 @@ namespace Emby.Server.Implementations.Library /// <param name="items">The items.</param> /// <param name="parent">The parent item.</param> /// <param name="cancellationToken">The cancellation token.</param> - public void CreateItems(IEnumerable<BaseItem> items, BaseItem parent, CancellationToken cancellationToken) + public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken) { - // Don't iterate multiple times - var itemsList = items.ToList(); - - _itemRepository.SaveItems(itemsList, cancellationToken); + _itemRepository.SaveItems(items, cancellationToken); - foreach (var item in itemsList) + foreach (var item in items) { RegisterItem(item); } if (ItemAdded != null) { - foreach (var item in itemsList) + foreach (var item in items) { // With the live tv guide this just creates too much noise if (item.SourceType != SourceType.Library) @@ -1896,12 +1921,17 @@ namespace Emby.Server.Implementations.Library } catch (ArgumentException) { - _logger.LogWarning("Cannot get image index for {0}", img.Path); + _logger.LogWarning("Cannot get image index for {ImagePath}", img.Path); + continue; + } + catch (Exception ex) when (ex is InvalidOperationException || ex is IOException) + { + _logger.LogWarning(ex, "Cannot fetch image from {ImagePath}", img.Path); continue; } - catch (InvalidOperationException) + catch (HttpRequestException ex) { - _logger.LogWarning("Cannot fetch image from {0}", img.Path); + _logger.LogWarning(ex, "Cannot fetch image from {ImagePath}. Http status code: {HttpStatus}", img.Path, ex.StatusCode); continue; } } @@ -1914,7 +1944,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Cannot get image dimensions for {0}", image.Path); + _logger.LogError(ex, "Cannot get image dimensions for {ImagePath}", image.Path); image.Width = 0; image.Height = 0; continue; @@ -1926,7 +1956,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Cannot compute blurhash for {0}", image.Path); + _logger.LogError(ex, "Cannot compute blurhash for {ImagePath}", image.Path); image.BlurHash = string.Empty; } @@ -1936,7 +1966,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Cannot update DateModified for {0}", image.Path); + _logger.LogError(ex, "Cannot update DateModified for {ImagePath}", image.Path); } } @@ -1949,14 +1979,7 @@ namespace Emby.Server.Implementations.Library { foreach (var item in items) { - if (item.IsFileProtocol) - { - ProviderManager.SaveMetadata(item, updateReason); - } - - item.DateLastSaved = DateTime.UtcNow; - - await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false); + await RunMetadataSavers(item, updateReason).ConfigureAwait(false); } _itemRepository.SaveItems(items, cancellationToken); @@ -1994,6 +2017,18 @@ namespace Emby.Server.Implementations.Library public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken); + public Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) + { + if (item.IsFileProtocol) + { + ProviderManager.SaveMetadata(item, updateReason); + } + + item.DateLastSaved = DateTime.UtcNow; + + return UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate); + } + /// <summary> /// Reports the item removed. /// </summary> @@ -2049,7 +2084,7 @@ namespace Emby.Server.Implementations.Library return new List<Folder>(); } - return GetCollectionFoldersInternal(item, GetUserRootFolder().Children.OfType<Folder>().ToList()); + return GetCollectionFoldersInternal(item, GetUserRootFolder().Children.OfType<Folder>()); } public List<Folder> GetCollectionFolders(BaseItem item, List<Folder> allUserRootChildren) @@ -2074,10 +2109,10 @@ namespace Emby.Server.Implementations.Library return GetCollectionFoldersInternal(item, allUserRootChildren); } - private static List<Folder> GetCollectionFoldersInternal(BaseItem item, List<Folder> allUserRootChildren) + private static List<Folder> GetCollectionFoldersInternal(BaseItem item, IEnumerable<Folder> allUserRootChildren) { return allUserRootChildren - .Where(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase) || i.PhysicalLocations.Contains(item.Path, StringComparer.OrdinalIgnoreCase)) + .Where(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase) || i.PhysicalLocations.Contains(item.Path.AsSpan(), StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -2085,9 +2120,9 @@ namespace Emby.Server.Implementations.Library { if (!(item is CollectionFolder collectionFolder)) { + // List.Find is more performant than FirstOrDefault due to enumerator allocation collectionFolder = GetCollectionFolders(item) - .OfType<CollectionFolder>() - .FirstOrDefault(); + .Find(folder => folder is CollectionFolder) as CollectionFolder; } return collectionFolder == null ? new LibraryOptions() : collectionFolder.GetLibraryOptions(); @@ -2445,11 +2480,35 @@ namespace Emby.Server.Implementations.Library new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files); } + public BaseItem GetParentItem(string parentId, Guid? userId) + { + if (string.IsNullOrEmpty(parentId)) + { + return GetParentItem((Guid?)null, userId); + } + + return GetParentItem(new Guid(parentId), userId); + } + + public BaseItem GetParentItem(Guid? parentId, Guid? userId) + { + if (parentId.HasValue) + { + return GetItemById(parentId.Value); + } + + if (userId.HasValue && userId != Guid.Empty) + { + return GetUserRootFolder(); + } + + return RootFolder; + } + /// <inheritdoc /> public bool IsVideoFile(string path) { - var resolver = new VideoResolver(GetNamingOptions()); - return resolver.IsVideoFile(path); + return VideoResolver.IsVideoFile(path, GetNamingOptions()); } /// <inheritdoc /> @@ -2464,7 +2523,7 @@ namespace Emby.Server.Implementations.Library public bool FillMissingEpisodeNumbersFromPath(Episode episode, bool forceRefresh) { var series = episode.Series; - bool? isAbsoluteNaming = series == null ? false : string.Equals(series.DisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase); + bool? isAbsoluteNaming = series != null && string.Equals(series.DisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase); if (!isAbsoluteNaming.Value) { // In other words, no filter applied @@ -2475,9 +2534,25 @@ namespace Emby.Server.Implementations.Library var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd; - var episodeInfo = episode.IsFileProtocol - ? resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming) ?? new Naming.TV.EpisodeInfo() - : new Naming.TV.EpisodeInfo(); + // TODO nullable - what are we trying to do there with empty episodeInfo? + EpisodeInfo episodeInfo = null; + if (episode.IsFileProtocol) + { + episodeInfo = resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming); + // Resolve from parent folder if it's not the Season folder + var parent = episode.GetParent(); + if (episodeInfo == null && parent.GetType() == typeof(Folder)) + { + episodeInfo = resolver.Resolve(parent.Path, true, null, null, isAbsoluteNaming); + if (episodeInfo != null) + { + // add the container + episodeInfo.Container = Path.GetExtension(episode.Path)?.TrimStart('.'); + } + } + } + + episodeInfo ??= new EpisodeInfo(episode.Path); try { @@ -2566,12 +2641,12 @@ namespace Emby.Server.Implementations.Library if (!episode.IndexNumberEnd.HasValue || forceRefresh) { - if (episode.IndexNumberEnd != episodeInfo.EndingEpsiodeNumber) + if (episode.IndexNumberEnd != episodeInfo.EndingEpisodeNumber) { changed = true; } - episode.IndexNumberEnd = episodeInfo.EndingEpsiodeNumber; + episode.IndexNumberEnd = episodeInfo.EndingEpisodeNumber; } if (!episode.ParentIndexNumber.HasValue || forceRefresh) @@ -2612,6 +2687,7 @@ namespace Emby.Server.Implementations.Library return changed; } + /// <inheritdoc /> public NamingOptions GetNamingOptions() { if (_namingOptions == null) @@ -2625,13 +2701,12 @@ namespace Emby.Server.Implementations.Library public ItemLookupInfo ParseName(string name) { - var resolver = new VideoResolver(GetNamingOptions()); - - var result = resolver.CleanDateTime(name); + var namingOptions = GetNamingOptions(); + var result = VideoResolver.CleanDateTime(name, namingOptions); return new ItemLookupInfo { - Name = resolver.TryCleanString(result.Name, out var newName) ? newName.ToString() : result.Name, + Name = VideoResolver.TryCleanString(result.Name, namingOptions, out var newName) ? newName.ToString() : result.Name, Year = result.Year }; } @@ -2645,9 +2720,7 @@ namespace Emby.Server.Implementations.Library .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) .ToList(); - var videoListResolver = new VideoListResolver(namingOptions); - - var videos = videoListResolver.Resolve(fileSystemChildren); + var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions); var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); @@ -2691,11 +2764,9 @@ namespace Emby.Server.Implementations.Library .SelectMany(i => _fileSystem.GetFiles(i.FullName, _videoFileExtensions, false, false)) .ToList(); - var videoListResolver = new VideoListResolver(namingOptions); + var videos = VideoListResolver.Resolve(fileSystemChildren, namingOptions); - var videos = videoListResolver.Resolve(fileSystemChildren); - - var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files.First().Path, StringComparison.OrdinalIgnoreCase)); + var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); if (currentVideo != null) { @@ -2727,6 +2798,7 @@ namespace Emby.Server.Implementations.Library public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem) { + string newPath; if (ownerItem != null) { var libraryOptions = GetLibraryOptions(ownerItem); @@ -2734,15 +2806,9 @@ namespace Emby.Server.Implementations.Library { foreach (var pathInfo in libraryOptions.PathInfos) { - if (string.IsNullOrWhiteSpace(pathInfo.Path) || string.IsNullOrWhiteSpace(pathInfo.NetworkPath)) + if (path.TryReplaceSubPath(pathInfo.Path, pathInfo.NetworkPath, out newPath)) { - continue; - } - - var substitutionResult = SubstitutePathInternal(path, pathInfo.Path, pathInfo.NetworkPath); - if (substitutionResult.Item2) - { - return substitutionResult.Item1; + return newPath; } } } @@ -2751,24 +2817,16 @@ namespace Emby.Server.Implementations.Library var metadataPath = _configurationManager.Configuration.MetadataPath; var metadataNetworkPath = _configurationManager.Configuration.MetadataNetworkPath; - if (!string.IsNullOrWhiteSpace(metadataPath) && !string.IsNullOrWhiteSpace(metadataNetworkPath)) + if (path.TryReplaceSubPath(metadataPath, metadataNetworkPath, out newPath)) { - var metadataSubstitutionResult = SubstitutePathInternal(path, metadataPath, metadataNetworkPath); - if (metadataSubstitutionResult.Item2) - { - return metadataSubstitutionResult.Item1; - } + return newPath; } foreach (var map in _configurationManager.Configuration.PathSubstitutions) { - if (!string.IsNullOrWhiteSpace(map.From)) + if (path.TryReplaceSubPath(map.From, map.To, out newPath)) { - var substitutionResult = SubstitutePathInternal(path, map.From, map.To); - if (substitutionResult.Item2) - { - return substitutionResult.Item1; - } + return newPath; } } @@ -2777,47 +2835,12 @@ namespace Emby.Server.Implementations.Library public string SubstitutePath(string path, string from, string to) { - return SubstitutePathInternal(path, from, to).Item1; - } - - private Tuple<string, bool> SubstitutePathInternal(string path, string from, string to) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - if (string.IsNullOrWhiteSpace(from)) + if (path.TryReplaceSubPath(from, to, out var newPath)) { - throw new ArgumentNullException(nameof(from)); - } - - if (string.IsNullOrWhiteSpace(to)) - { - throw new ArgumentNullException(nameof(to)); - } - - from = from.Trim(); - to = to.Trim(); - - var newPath = path.Replace(from, to, StringComparison.OrdinalIgnoreCase); - var changed = false; - - if (!string.Equals(newPath, path, StringComparison.Ordinal)) - { - if (to.IndexOf('/', StringComparison.Ordinal) != -1) - { - newPath = newPath.Replace('\\', '/'); - } - else - { - newPath = newPath.Replace('/', '\\'); - } - - changed = true; + return newPath; } - return new Tuple<string, bool>(newPath, changed); + return path; } private void SetExtraTypeFromFilename(Video item) @@ -2875,12 +2898,20 @@ namespace Emby.Server.Implementations.Library public void UpdatePeople(BaseItem item, List<PersonInfo> people) { + UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult(); + } + + /// <inheritdoc /> + public async Task UpdatePeopleAsync(BaseItem item, List<PersonInfo> people, CancellationToken cancellationToken) + { if (!item.SupportsPeople) { return; } _itemRepository.UpdatePeople(item.Id, people); + + await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false); } public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex) @@ -2897,7 +2928,7 @@ namespace Emby.Server.Implementations.Library return item.GetImageInfo(image.Type, imageIndex); } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue && (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden)) @@ -2916,7 +2947,7 @@ namespace Emby.Server.Implementations.Library throw new InvalidOperationException(); } - public async Task AddVirtualFolder(string name, string collectionType, LibraryOptions options, bool refreshLibrary) + public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) { if (string.IsNullOrWhiteSpace(name)) { @@ -2950,9 +2981,9 @@ namespace Emby.Server.Implementations.Library { Directory.CreateDirectory(virtualFolderPath); - if (!string.IsNullOrEmpty(collectionType)) + if (collectionType != null) { - var path = Path.Combine(virtualFolderPath, collectionType + ".collection"); + var path = Path.Combine(virtualFolderPath, collectionType.ToString().ToLowerInvariant() + ".collection"); File.WriteAllBytes(path, Array.Empty<byte>()); } @@ -2984,6 +3015,58 @@ namespace Emby.Server.Implementations.Library } } + private async Task SavePeopleMetadataAsync(IEnumerable<PersonInfo> people, CancellationToken cancellationToken) + { + var personsToSave = new List<BaseItem>(); + + foreach (var person in people) + { + cancellationToken.ThrowIfCancellationRequested(); + + var itemUpdateType = ItemUpdateType.MetadataDownload; + var saveEntity = false; + var personEntity = GetPerson(person.Name); + + // if PresentationUniqueKey is empty it's likely a new item. + if (string.IsNullOrEmpty(personEntity.PresentationUniqueKey)) + { + personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); + saveEntity = true; + } + + foreach (var id in person.ProviderIds) + { + if (!string.Equals(personEntity.GetProviderId(id.Key), id.Value, StringComparison.OrdinalIgnoreCase)) + { + personEntity.SetProviderId(id.Key, id.Value); + saveEntity = true; + } + } + + if (!string.IsNullOrWhiteSpace(person.ImageUrl) && !personEntity.HasImage(ImageType.Primary)) + { + personEntity.SetImage( + new ItemImageInfo + { + Path = person.ImageUrl, + Type = ImageType.Primary + }, + 0); + + saveEntity = true; + itemUpdateType = ItemUpdateType.ImageUpdate; + } + + if (saveEntity) + { + personsToSave.Add(personEntity); + await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); + } + } + + CreateItems(personsToSave, null, CancellationToken.None); + } + private void StartScanInBackground() { Task.Run(() => diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 041619d1e..806269182 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -5,16 +7,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 Jellyfin.Extensions.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 +26,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.Options; - 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 +49,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 +86,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 67cf8bf5b..91c9e61cf 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -1,16 +1,21 @@ +#nullable disable + #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; 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 Jellyfin.Extensions.Json; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -22,7 +27,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 @@ -35,7 +39,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; @@ -43,8 +46,9 @@ namespace Emby.Server.Implementations.Library private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; - private readonly Dictionary<string, ILiveStream> _openStreams = new Dictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase); + 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.Options; private IMediaSourceProvider[] _providers; @@ -55,7 +59,6 @@ namespace Emby.Server.Implementations.Library IUserManager userManager, ILibraryManager libraryManager, ILogger<MediaSourceManager> logger, - IJsonSerializer jsonSerializer, IFileSystem fileSystem, IUserDataManager userDataManager, IMediaEncoder mediaEncoder) @@ -64,7 +67,6 @@ namespace Emby.Server.Implementations.Library _userManager = userManager; _libraryManager = libraryManager; _logger = logger; - _jsonSerializer = jsonSerializer; _fileSystem = fileSystem; _userDataManager = userDataManager; _mediaEncoder = mediaEncoder; @@ -199,10 +201,15 @@ namespace Emby.Server.Implementations.Library { source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding); } + else if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) + { + source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding); + source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); + } } } - return SortMediaSources(list).Where(i => i.Type != MediaSourceType.Placeholder).ToList(); + return SortMediaSources(list); } public MediaProtocol GetPathProtocol(string path) @@ -345,7 +352,7 @@ namespace Emby.Server.Implementations.Library private string[] NormalizeLanguage(string language) { - if (language == null) + if (string.IsNullOrEmpty(language)) { return Array.Empty<string>(); } @@ -374,8 +381,7 @@ namespace Emby.Server.Implementations.Library } } - var preferredSubs = string.IsNullOrEmpty(user.SubtitleLanguagePreference) - ? Array.Empty<string>() : NormalizeLanguage(user.SubtitleLanguagePreference); + var preferredSubs = NormalizeLanguage(user.SubtitleLanguagePreference); var defaultAudioIndex = source.DefaultAudioStreamIndex; var audioLangage = defaultAudioIndex == null @@ -404,9 +410,7 @@ namespace Emby.Server.Implementations.Library } } - var preferredAudio = string.IsNullOrEmpty(user.AudioLanguagePreference) - ? Array.Empty<string>() - : NormalizeLanguage(user.AudioLanguagePreference); + var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); } @@ -436,7 +440,7 @@ namespace Emby.Server.Implementations.Library } } - private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources) + private static List<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources) { return sources.OrderBy(i => { @@ -451,8 +455,9 @@ namespace Emby.Server.Implementations.Library { var stream = i.VideoStream; - return stream == null || stream.Width == null ? 0 : stream.Width.Value; + return stream?.Width ?? 0; }) + .Where(i => i.Type != MediaSourceType.Placeholder) .ToList(); } @@ -503,7 +508,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); } @@ -515,9 +520,9 @@ namespace Emby.Server.Implementations.Library } // TODO: @bond Fix - var json = _jsonSerializer.SerializeToString(mediaSource); + var json = JsonSerializer.SerializeToUtf8Bytes(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)) { @@ -582,29 +587,11 @@ namespace Emby.Server.Implementations.Library mediaSource.InferTotalBitrate(); } - public async Task<IDirectStreamProvider> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken) + public Task<IDirectStreamProvider> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken) { - await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try - { - var info = _openStreams.Values.FirstOrDefault(i => - { - var liveStream = i as ILiveStream; - if (liveStream != null) - { - return string.Equals(liveStream.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase); - } - - return false; - }); + var info = _openStreams.FirstOrDefault(i => i.Value != null && string.Equals(i.Value.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase)); - return info as IDirectStreamProvider; - } - finally - { - _liveStreamSemaphore.Release(); - } + return Task.FromResult(info.Value as IDirectStreamProvider); } public async Task<LiveStreamResponse> OpenLiveStream(LiveStreamRequest request, CancellationToken cancellationToken) @@ -651,7 +638,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"); } @@ -687,7 +675,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); } @@ -793,29 +782,20 @@ namespace Emby.Server.Implementations.Library return new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info as IDirectStreamProvider); } - private async Task<ILiveStream> GetLiveStreamInfo(string id, CancellationToken cancellationToken) + private Task<ILiveStream> GetLiveStreamInfo(string id, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(id)) { throw new ArgumentNullException(nameof(id)); } - await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + if (_openStreams.TryGetValue(id, out ILiveStream info)) { - if (_openStreams.TryGetValue(id, out ILiveStream info)) - { - return info; - } - else - { - throw new ResourceNotFoundException(); - } + return Task.FromResult(info); } - finally + else { - _liveStreamSemaphore.Release(); + return Task.FromException<ILiveStream>(new ResourceNotFoundException()); } } @@ -844,7 +824,7 @@ namespace Emby.Server.Implementations.Library if (liveStream.ConsumerCount <= 0) { - _openStreams.Remove(id); + _openStreams.TryRemove(id, out _); _logger.LogInformation("Closing live stream {0}", id); @@ -866,7 +846,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("Key can't be empty.", nameof(key)); } - var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2); + var keys = key.Split(LiveStreamIdDelimeter, 2); var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index 179e0ed98..b833122ea 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -101,7 +103,7 @@ namespace Emby.Server.Implementations.Library private static IEnumerable<MediaStream> GetSortedStreams(IEnumerable<MediaStream> streams, MediaStreamType type, string[] languagePreferences) { - // Give some preferance to external text subs for better performance + // Give some preference to external text subs for better performance return streams.Where(i => i.Type == type) .OrderBy(i => { diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 877fdec86..06300adeb 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -49,7 +51,7 @@ namespace Emby.Server.Implementations.Library var genres = item .GetRecursiveChildren(user, new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, DtoOptions = dtoOptions }) .Cast<Audio>() @@ -86,7 +88,7 @@ namespace Emby.Server.Implementations.Library { return _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, GenreIds = genreIds.ToArray(), @@ -100,8 +102,7 @@ namespace Emby.Server.Implementations.Library public List<BaseItem> GetInstantMixFromItem(BaseItem item, User user, DtoOptions dtoOptions) { - var genre = item as MusicGenre; - if (genre != null) + if (item is MusicGenre genre) { return GetInstantMixFromGenreIds(new[] { item.Id }, user, dtoOptions); } diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 06ff3e611..86b8039fa 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -1,7 +1,6 @@ -#nullable enable - using System; -using System.Text.RegularExpressions; +using System.Diagnostics.CodeAnalysis; +using MediaBrowser.Common.Providers; namespace Emby.Server.Implementations.Library { @@ -41,11 +40,78 @@ namespace Emby.Server.Implementations.Library // for imdbid we also accept pattern matching if (string.Equals(attribute, "imdbid", StringComparison.OrdinalIgnoreCase)) { - var m = Regex.Match(str, "tt([0-9]{7,8})", RegexOptions.IgnoreCase); - return m.Success ? m.Value : null; + var match = ProviderIdParsers.TryFindImdbId(str, out var imdbId); + return match ? imdbId.ToString() : null; } return null; } + + /// <summary> + /// Replaces a sub path with another sub path and normalizes the final path. + /// </summary> + /// <param name="path">The original path.</param> + /// <param name="subPath">The original sub path.</param> + /// <param name="newSubPath">The new sub path.</param> + /// <param name="newPath">The result of the sub path replacement</param> + /// <returns>The path after replacing the sub path.</returns> + /// <exception cref="ArgumentNullException"><paramref name="path" />, <paramref name="newSubPath" /> or <paramref name="newSubPath" /> is empty.</exception> + public static bool TryReplaceSubPath( + [NotNullWhen(true)] this string? path, + [NotNullWhen(true)] string? subPath, + [NotNullWhen(true)] string? newSubPath, + [NotNullWhen(true)] out string? newPath) + { + newPath = null; + + if (string.IsNullOrEmpty(path) + || string.IsNullOrEmpty(subPath) + || string.IsNullOrEmpty(newSubPath) + || subPath.Length > path.Length) + { + return false; + } + + char oldDirectorySeparatorChar; + char newDirectorySeparatorChar; + // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162 + // The reasoning behind this is that a forward slash likely means it's a Linux path and + // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much). + if (newSubPath.Contains('/', StringComparison.Ordinal)) + { + oldDirectorySeparatorChar = '\\'; + newDirectorySeparatorChar = '/'; + } + else + { + oldDirectorySeparatorChar = '/'; + newDirectorySeparatorChar = '\\'; + } + + path = path.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); + subPath = subPath.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); + + // We have to ensure that the sub path ends with a directory separator otherwise we'll get weird results + // when the sub path matches a similar but in-complete subpath + var oldSubPathEndsWithSeparator = subPath[^1] == newDirectorySeparatorChar; + if (!path.StartsWith(subPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (path.Length > subPath.Length + && !oldSubPathEndsWithSeparator + && path[subPath.Length] != newDirectorySeparatorChar) + { + return false; + } + + var newSubPathTrimmed = newSubPath.AsSpan().TrimEnd(newDirectorySeparatorChar); + // Ensure that the path with the old subpath removed starts with a leading dir separator + int idx = oldSubPathEndsWithSeparator ? subPath.Length - 1 : subPath.Length; + newPath = string.Concat(newSubPathTrimmed, path.AsSpan(idx)); + + return true; + } } } diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs index 4e4cac75b..ac75e5d3a 100644 --- a/Emby.Server.Implementations/Library/ResolverHelper.cs +++ b/Emby.Server.Implementations/Library/ResolverHelper.cs @@ -18,11 +18,10 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="item">The item.</param> /// <param name="parent">The parent.</param> - /// <param name="fileSystem">The file system.</param> /// <param name="libraryManager">The library manager.</param> /// <param name="directoryService">The directory service.</param> - /// <exception cref="ArgumentException">Item must have a path</exception> - public static void SetInitialItemValues(BaseItem item, Folder parent, IFileSystem fileSystem, ILibraryManager libraryManager, IDirectoryService directoryService) + /// <exception cref="ArgumentException">Item must have a path.</exception> + public static void SetInitialItemValues(BaseItem item, Folder? parent, ILibraryManager libraryManager, IDirectoryService directoryService) { // This version of the below method has no ItemResolveArgs, so we have to require the path already being set if (string.IsNullOrEmpty(item.Path)) @@ -43,9 +42,14 @@ namespace Emby.Server.Implementations.Library // Make sure DateCreated and DateModified have values var fileInfo = directoryService.GetFile(item.Path); - SetDateCreated(item, fileSystem, fileInfo); + if (fileInfo == null) + { + throw new FileNotFoundException("Can't find item path.", item.Path); + } - EnsureName(item, item.Path, fileInfo); + SetDateCreated(item, fileInfo); + + EnsureName(item, fileInfo); } /// <summary> @@ -72,9 +76,9 @@ namespace Emby.Server.Implementations.Library item.Id = libraryManager.GetNewItemId(item.Path, item.GetType()); // Make sure the item has a name - EnsureName(item, item.Path, args.FileInfo); + EnsureName(item, args.FileInfo); - item.IsLocked = item.Path.IndexOf("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) != -1 || + item.IsLocked = item.Path.Contains("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) || item.GetParents().Any(i => i.IsLocked); // Make sure DateCreated and DateModified have values @@ -84,29 +88,16 @@ namespace Emby.Server.Implementations.Library /// <summary> /// Ensures the name. /// </summary> - private static void EnsureName(BaseItem item, string fullPath, FileSystemMetadata fileInfo) + private static void EnsureName(BaseItem item, FileSystemMetadata fileInfo) { // If the subclass didn't supply a name, add it here - if (string.IsNullOrEmpty(item.Name) && !string.IsNullOrEmpty(fullPath)) + if (string.IsNullOrEmpty(item.Name) && !string.IsNullOrEmpty(item.Path)) { - var fileName = fileInfo == null ? Path.GetFileName(fullPath) : fileInfo.Name; - - item.Name = GetDisplayName(fileName, fileInfo != null && fileInfo.IsDirectory); + item.Name = fileInfo.IsDirectory ? fileInfo.Name : Path.GetFileNameWithoutExtension(fileInfo.Name); } } /// <summary> - /// Gets the display name. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="isDirectory">if set to <c>true</c> [is directory].</param> - /// <returns>System.String.</returns> - private static string GetDisplayName(string path, bool isDirectory) - { - return isDirectory ? Path.GetFileName(path) : Path.GetFileNameWithoutExtension(path); - } - - /// <summary> /// Ensures DateCreated and DateModified have values. /// </summary> /// <param name="fileSystem">The file system.</param> @@ -114,21 +105,6 @@ namespace Emby.Server.Implementations.Library /// <param name="args">The args.</param> private static void EnsureDates(IFileSystem fileSystem, BaseItem item, ItemResolveArgs args) { - if (fileSystem == null) - { - throw new ArgumentNullException(nameof(fileSystem)); - } - - if (item == null) - { - throw new ArgumentNullException(nameof(item)); - } - - if (args == null) - { - throw new ArgumentNullException(nameof(args)); - } - // See if a different path came out of the resolver than what went in if (!fileSystem.AreEqual(args.Path, item.Path)) { @@ -136,7 +112,7 @@ namespace Emby.Server.Implementations.Library if (childData != null) { - SetDateCreated(item, fileSystem, childData); + SetDateCreated(item, childData); } else { @@ -144,17 +120,17 @@ namespace Emby.Server.Implementations.Library if (fileData.Exists) { - SetDateCreated(item, fileSystem, fileData); + SetDateCreated(item, fileData); } } } else { - SetDateCreated(item, fileSystem, args.FileInfo); + SetDateCreated(item, args.FileInfo); } } - private static void SetDateCreated(BaseItem item, IFileSystem fileSystem, FileSystemMetadata info) + private static void SetDateCreated(BaseItem item, FileSystemMetadata? info) { var config = BaseItem.ConfigurationManager.GetMetadataConfiguration(); @@ -163,7 +139,7 @@ namespace Emby.Server.Implementations.Library // directoryService.getFile may return null if (info != null) { - var dateCreated = fileSystem.GetCreationTimeUtc(info); + var dateCreated = info.CreationTimeUtc; if (dateCreated.Equals(DateTime.MinValue)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index 03059e6d3..e893d6335 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -30,9 +32,10 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// Gets the priority. /// </summary> /// <value>The priority.</value> - public override ResolverPriority Priority => ResolverPriority.Fourth; + public override ResolverPriority Priority => ResolverPriority.Fifth; - public MultiItemResolverResult ResolveMultiple(Folder parent, + public MultiItemResolverResult ResolveMultiple( + Folder parent, List<FileSystemMetadata> files, string collectionType, IDirectoryService directoryService) @@ -50,7 +53,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return result; } - private MultiItemResolverResult ResolveMultipleInternal(Folder parent, + private MultiItemResolverResult ResolveMultipleInternal( + Folder parent, List<FileSystemMetadata> files, string collectionType, IDirectoryService directoryService) @@ -199,7 +203,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio continue; } - var firstMedia = resolvedItem.Files.First(); + if (resolvedItem.Files.Count == 0) + { + continue; + } + + var firstMedia = resolvedItem.Files[0]; var libraryItem = new T { diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index 79b6dded3..8e1eccb10 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -1,5 +1,10 @@ +#nullable disable + using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Emby.Naming.Audio; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -37,7 +42,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// Gets the priority. /// </summary> /// <value>The priority.</value> - public override ResolverPriority Priority => ResolverPriority.Second; + public override ResolverPriority Priority => ResolverPriority.Third; /// <summary> /// Resolves the specified args. @@ -113,52 +118,48 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio IFileSystem fileSystem, ILibraryManager libraryManager) { + // check for audio files before digging down into directories + var foundAudioFile = list.Any(fileSystemInfo => !fileSystemInfo.IsDirectory && libraryManager.IsAudioFile(fileSystemInfo.FullName)); + if (foundAudioFile) + { + // at least one audio file exists + return true; + } + + if (!allowSubfolders) + { + // not music since no audio file exists and we're not looking into subfolders + return false; + } + var discSubfolderCount = 0; - var notMultiDisc = false; var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); var parser = new AlbumParser(namingOptions); - foreach (var fileSystemInfo in list) + + var directories = list.Where(fileSystemInfo => fileSystemInfo.IsDirectory); + + var result = Parallel.ForEach(directories, (fileSystemInfo, state) => { - if (fileSystemInfo.IsDirectory) + var path = fileSystemInfo.FullName; + var hasMusic = ContainsMusic(directoryService.GetFileSystemEntries(path), false, directoryService, logger, fileSystem, libraryManager); + + if (hasMusic) { - if (allowSubfolders) + if (parser.IsMultiPart(path)) { - if (notMultiDisc) - { - continue; - } - - var path = fileSystemInfo.FullName; - var hasMusic = ContainsMusic(directoryService.GetFileSystemEntries(path), false, directoryService, logger, fileSystem, libraryManager); - - if (hasMusic) - { - if (parser.IsMultiPart(path)) - { - logger.LogDebug("Found multi-disc folder: " + path); - discSubfolderCount++; - } - else - { - // If there are folders underneath with music that are not multidisc, then this can't be a multi-disc album - notMultiDisc = true; - } - } + logger.LogDebug("Found multi-disc folder: " + path); + Interlocked.Increment(ref discSubfolderCount); } - } - else - { - var fullName = fileSystemInfo.FullName; - - if (libraryManager.IsAudioFile(fullName)) + else { - return true; + // If there are folders underneath with music that are not multidisc, then this can't be a multi-disc album + state.Stop(); } } - } + }); - if (notMultiDisc) + if (!result.IsCompleted) { return false; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index 5f5cd0e92..3d2ae95d2 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -1,5 +1,8 @@ +#nullable disable + using System; using System.Linq; +using System.Threading.Tasks; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -78,11 +81,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return new MusicArtist(); } - if (_config.Configuration.EnableSimpleArtistDetection) - { - return null; - } - // Avoid mis-identifying top folders if (args.Parent.IsRoot) { @@ -94,7 +92,18 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var albumResolver = new MusicAlbumResolver(_logger, _fileSystem, _libraryManager); // If we contain an album assume we are an artist folder - return args.FileSystemChildren.Where(i => i.IsDirectory).Any(i => albumResolver.IsMusicAlbum(i.FullName, directoryService)) ? new MusicArtist() : null; + var directories = args.FileSystemChildren.Where(i => i.IsDirectory); + + var result = Parallel.ForEach(directories, (fileSystemInfo, state) => + { + if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, directoryService)) + { + // stop once we see a music album + state.Stop(); + } + }); + + return !result.IsCompleted ? new MusicArtist() : null; } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index 2f5e46038..cdb492022 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -30,7 +32,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>`0.</returns> - protected override T Resolve(ItemResolveArgs args) + public override T Resolve(ItemResolveArgs args) { return ResolveVideo<T>(args, false); } @@ -42,14 +44,12 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <param name="args">The args.</param> /// <param name="parseName">if set to <c>true</c> [parse name].</param> /// <returns>``0.</returns> - protected TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName) + protected virtual TVideoType ResolveVideo<TVideoType>(ItemResolveArgs args, bool parseName) where TVideoType : Video, new() { - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); + var namingOptions = LibraryManager.GetNamingOptions(); // If the path is a file check for a matching extensions - var parser = new VideoResolver(namingOptions); - if (args.IsDirectory) { TVideoType video = null; @@ -64,7 +64,7 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (IsDvdDirectory(child.FullName, filename, args.DirectoryService)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -82,7 +82,7 @@ namespace Emby.Server.Implementations.Library.Resolvers if (IsBluRayDirectory(child.FullName, filename, args.DirectoryService)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -100,7 +100,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } else if (IsDvdFile(filename)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -130,7 +130,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } else { - var videoInfo = parser.Resolve(args.Path, false, false); + var videoInfo = VideoResolver.Resolve(args.Path, false, namingOptions, false); if (videoInfo == null) { @@ -165,13 +165,13 @@ namespace Emby.Server.Implementations.Library.Resolvers protected void SetVideoType(Video video, VideoFileInfo videoInfo) { - var extension = Path.GetExtension(video.Path); - video.VideoType = string.Equals(extension, ".iso", StringComparison.OrdinalIgnoreCase) || - string.Equals(extension, ".img", StringComparison.OrdinalIgnoreCase) ? - VideoType.Iso : - VideoType.VideoFile; + var extension = Path.GetExtension(video.Path.AsSpan()); + video.VideoType = extension.Equals(".iso", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".img", StringComparison.OrdinalIgnoreCase) + ? VideoType.Iso + : VideoType.VideoFile; - video.IsShortcut = string.Equals(extension, ".strm", StringComparison.OrdinalIgnoreCase); + video.IsShortcut = extension.Equals(".strm", StringComparison.OrdinalIgnoreCase); video.IsPlaceHolder = videoInfo.IsStub; if (videoInfo.IsStub) @@ -193,11 +193,11 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (video.VideoType == VideoType.Iso) { - if (video.Path.IndexOf("dvd", StringComparison.OrdinalIgnoreCase) != -1) + if (video.Path.Contains("dvd", StringComparison.OrdinalIgnoreCase)) { video.IsoType = IsoType.Dvd; } - else if (video.Path.IndexOf("bluray", StringComparison.OrdinalIgnoreCase) != -1) + else if (video.Path.Contains("bluray", StringComparison.OrdinalIgnoreCase)) { video.IsoType = IsoType.BluRay; } @@ -250,10 +250,7 @@ namespace Emby.Server.Implementations.Library.Resolvers protected void Set3DFormat(Video video) { - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); - - var resolver = new Format3DParser(namingOptions); - var result = resolver.Parse(video.Path); + var result = Format3DParser.Parse(video.Path, LibraryManager.GetNamingOptions()); Set3DFormat(video, result.Is3D, result.Format3D); } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 86a5d8b7d..68076730b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -11,9 +13,9 @@ 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) + public override Book Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); @@ -50,7 +52,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books var fileExtension = Path.GetExtension(f.FullName) ?? string.Empty; - return _validExtensions.Contains(fileExtension, + return _validExtensions.Contains( + fileExtension, StringComparer .OrdinalIgnoreCase); }).ToList(); diff --git a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs index 7dbce7a6e..7aaee017d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; diff --git a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs index 9ca76095b..fa45ccf84 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; @@ -12,6 +14,12 @@ namespace Emby.Server.Implementations.Library.Resolvers where T : BaseItem, new() { /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public virtual ResolverPriority Priority => ResolverPriority.First; + + /// <summary> /// Resolves the specified args. /// </summary> /// <param name="args">The args.</param> @@ -22,12 +30,6 @@ namespace Emby.Server.Implementations.Library.Resolvers } /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - public virtual ResolverPriority Priority => ResolverPriority.First; - - /// <summary> /// Sets initial values on the newly resolved item. /// </summary> /// <param name="item">The item.</param> diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs index 295e9e120..69d71d0d9 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using System.IO; using MediaBrowser.Controller.Entities; diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index baf0e3cf9..889e29a6b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -1,9 +1,12 @@ +#nullable disable + using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Video; +using Jellyfin.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -47,7 +50,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// Gets the priority. /// </summary> /// <value>The priority.</value> - public override ResolverPriority Priority => ResolverPriority.Third; + public override ResolverPriority Priority => ResolverPriority.Fourth; /// <inheritdoc /> public MultiItemResolverResult ResolveMultiple( @@ -69,6 +72,110 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return result; } + /// <summary> + /// Resolves the specified args. + /// </summary> + /// <param name="args">The args.</param> + /// <returns>Video.</returns> + public override Video Resolve(ItemResolveArgs args) + { + var collectionType = args.GetCollectionType(); + + // Find movies with their own folders + if (args.IsDirectory) + { + if (IsInvalid(args.Parent, collectionType)) + { + return null; + } + + var files = args.FileSystemChildren + .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) + .ToList(); + + if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) + { + return FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + } + + if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase)) + { + return FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); + } + + if (string.IsNullOrEmpty(collectionType)) + { + // Owned items will be caught by the plain video resolver + if (args.Parent == null) + { + // return FindMovie<Video>(args.Path, args.Parent, files, args.DirectoryService, collectionType); + return null; + } + + if (args.HasParent<Series>()) + { + return null; + } + + { + return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); + } + } + + if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) + { + return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); + } + + return null; + } + + // Handle owned items + if (args.Parent == null) + { + return base.Resolve(args); + } + + if (IsInvalid(args.Parent, collectionType)) + { + return null; + } + + Video item = null; + + if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) + { + item = ResolveVideo<MusicVideo>(args, false); + } + + // To find a movie file, the collection type must be movies or boxsets + else if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) + { + item = ResolveVideo<Movie>(args, true); + } + else if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) || + string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase)) + { + item = ResolveVideo<Video>(args, false); + } + else if (string.IsNullOrEmpty(collectionType)) + { + if (args.HasParent<Series>()) + { + return null; + } + + item = ResolveVideo<Video>(args, false); + } + + if (item != null) + { + item.IsInMixedFolder = true; + } + + return item; + } + private MultiItemResolverResult ResolveMultipleInternal( Folder parent, List<FileSystemMetadata> files, @@ -151,10 +258,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } } - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); + var namingOptions = LibraryManager.GetNamingOptions(); - var resolver = new VideoListResolver(namingOptions); - var resolverResult = resolver.Resolve(files, suppportMultiEditions).ToList(); + var resolverResult = VideoListResolver.Resolve(files, namingOptions, suppportMultiEditions).ToList(); var result = new MultiItemResolverResult { @@ -217,110 +323,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } /// <summary> - /// Resolves the specified args. - /// </summary> - /// <param name="args">The args.</param> - /// <returns>Video.</returns> - protected override Video Resolve(ItemResolveArgs args) - { - var collectionType = args.GetCollectionType(); - - // Find movies with their own folders - if (args.IsDirectory) - { - if (IsInvalid(args.Parent, collectionType)) - { - return null; - } - - var files = args.FileSystemChildren - .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) - .ToList(); - - if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) - { - return FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); - } - - if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase)) - { - return FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); - } - - if (string.IsNullOrEmpty(collectionType)) - { - // Owned items will be caught by the plain video resolver - if (args.Parent == null) - { - // return FindMovie<Video>(args.Path, args.Parent, files, args.DirectoryService, collectionType); - return null; - } - - if (args.HasParent<Series>()) - { - return null; - } - - { - return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); - } - } - - if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) - { - return FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true); - } - - return null; - } - - // Handle owned items - if (args.Parent == null) - { - return base.Resolve(args); - } - - if (IsInvalid(args.Parent, collectionType)) - { - return null; - } - - Video item = null; - - if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase)) - { - item = ResolveVideo<MusicVideo>(args, false); - } - - // To find a movie file, the collection type must be movies or boxsets - else if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase)) - { - item = ResolveVideo<Movie>(args, true); - } - else if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) || - string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase)) - { - item = ResolveVideo<Video>(args, false); - } - else if (string.IsNullOrEmpty(collectionType)) - { - if (args.HasParent<Series>()) - { - return null; - } - - item = ResolveVideo<Video>(args, false); - } - - if (item != null) - { - item.IsInMixedFolder = true; - } - - return item; - } - - /// <summary> /// Sets the initial item values. /// </summary> /// <param name="item">The item.</param> @@ -376,7 +378,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies { var multiDiscFolders = new List<FileSystemMetadata>(); - var libraryOptions = args.GetLibraryOptions(); + var libraryOptions = args.LibraryOptions; var supportPhotos = string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && libraryOptions.EnablePhotos; var photos = new List<FileSystemMetadata>(); @@ -535,7 +537,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return returnVideo; } - private bool IsInvalid(Folder parent, string collectionType) + private bool IsInvalid(Folder parent, ReadOnlySpan<char> collectionType) { if (parent != null) { @@ -545,12 +547,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } } - if (string.IsNullOrEmpty(collectionType)) + if (collectionType.IsEmpty) { return false; } - return !_validCollectionTypes.Contains(collectionType, StringComparer.OrdinalIgnoreCase); + return !_validCollectionTypes.Contains(collectionType, StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs index 3ac837057..534bc80dd 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoAlbumResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; @@ -13,7 +15,7 @@ namespace Emby.Server.Implementations.Library.Resolvers public class PhotoAlbumResolver : FolderResolver<PhotoAlbum> { private readonly IImageProcessor _imageProcessor; - private ILibraryManager _libraryManager; + private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="PhotoAlbumResolver"/> class. @@ -26,6 +28,9 @@ namespace Emby.Server.Implementations.Library.Resolvers _libraryManager = libraryManager; } + /// <inheritdoc /> + public override ResolverPriority Priority => ResolverPriority.Second; + /// <summary> /// Resolves the specified args. /// </summary> @@ -39,8 +44,8 @@ namespace Emby.Server.Implementations.Library.Resolvers // Must be an image file within a photo collection var collectionType = args.GetCollectionType(); - if (string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase) || - (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.GetLibraryOptions().EnablePhotos)) + if (string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase) + || (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.LibraryOptions.EnablePhotos)) { if (HasPhotos(args)) { @@ -84,8 +89,5 @@ namespace Emby.Server.Implementations.Library.Resolvers return false; } - - /// <inheritdoc /> - public override ResolverPriority Priority => ResolverPriority.Second; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs index bcfcee9c6..57bf40e9e 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -47,7 +49,7 @@ namespace Emby.Server.Implementations.Library.Resolvers var collectionType = args.CollectionType; if (string.Equals(collectionType, CollectionType.Photos, StringComparison.OrdinalIgnoreCase) - || (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.GetLibraryOptions().EnablePhotos)) + || (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase) && args.LibraryOptions.EnablePhotos)) { if (IsImageFile(args.Path, _imageProcessor)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 41561916f..ecd44be47 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -41,7 +43,7 @@ namespace Emby.Server.Implementations.Library.Resolvers } // It's a directory-based playlist if the directory contains a playlist file - var filePaths = Directory.EnumerateFiles(args.Path); + var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase))) { return new Playlist @@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Library.Resolvers { Path = args.Path, Name = Path.GetFileNameWithoutExtension(args.Path), - IsInMixedFolder = true + IsInMixedFolder = true, + PlaylistMediaType = MediaType.Audio }; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs index 99f304190..7b4e14334 100644 --- a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index 2f7af60c0..d6ae91056 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -1,5 +1,8 @@ +#nullable disable + using System; using System.Linq; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -12,11 +15,20 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV public class EpisodeResolver : BaseVideoResolver<Episode> { /// <summary> + /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public EpisodeResolver(ILibraryManager libraryManager) + : base(libraryManager) + { + } + + /// <summary> /// Resolves the specified args. /// </summary> /// <param name="args">The args.</param> /// <returns>Episode.</returns> - protected override Episode Resolve(ItemResolveArgs args) + public override Episode Resolve(ItemResolveArgs args) { var parent = args.Parent; @@ -25,30 +37,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - var season = parent as Season; - // Just in case the user decided to nest episodes. // Not officially supported but in some cases we can handle it. - if (season == null) - { - season = parent.GetParents().OfType<Season>().FirstOrDefault(); - } - // If the parent is a Season or Series, then this is an Episode if the VideoResolver returns something + var season = parent as Season ?? parent.GetParents().OfType<Season>().FirstOrDefault(); + + // If the parent is a Season or Series and the parent is not an extras folder, then this is an Episode if the VideoResolver returns something // Also handle flat tv folders - if (season != null || - string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) || - args.HasParent<Series>()) + if ((season != null || + string.Equals(args.GetCollectionType(), CollectionType.TvShows, StringComparison.OrdinalIgnoreCase) || + args.HasParent<Series>()) + && (parent is Series || !BaseItem.AllExtrasTypesFolderNames.Contains(parent.Name, StringComparer.OrdinalIgnoreCase))) { var episode = ResolveVideo<Episode>(args, false); if (episode != null) { - var series = parent as Series; - if (series == null) - { - series = parent.GetParents().OfType<Series>().FirstOrDefault(); - } + var series = parent as Series ?? parent.GetParents().OfType<Series>().FirstOrDefault(); if (series != null) { @@ -74,14 +79,5 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - - /// <summary> - /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. - /// </summary> - /// <param name="libraryManager">The library manager.</param> - public EpisodeResolver(ILibraryManager libraryManager) - : base(libraryManager) - { - } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 3332e1806..7d707df18 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + using System.Globalization; using Emby.Naming.TV; using MediaBrowser.Controller.Entities.TV; @@ -88,7 +90,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV CultureInfo.InvariantCulture, _localization.GetLocalizedString("NameSeasonNumber"), seasonNumber, - args.GetLibraryOptions().PreferredMetadataLanguage); + args.LibraryOptions.PreferredMetadataLanguage); } return season; diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index 732bfd94d..a1562abd3 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -19,19 +21,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// </summary> public class SeriesResolver : FolderResolver<Series> { - private readonly IFileSystem _fileSystem; private readonly ILogger<SeriesResolver> _logger; private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="SeriesResolver"/> class. /// </summary> - /// <param name="fileSystem">The file system.</param> /// <param name="logger">The logger.</param> /// <param name="libraryManager">The library manager.</param> - public SeriesResolver(IFileSystem fileSystem, ILogger<SeriesResolver> logger, ILibraryManager libraryManager) + public SeriesResolver(ILogger<SeriesResolver> logger, ILibraryManager libraryManager) { - _fileSystem = fileSystem; _logger = logger; _libraryManager = libraryManager; } @@ -59,15 +58,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var collectionType = args.GetCollectionType(); if (string.Equals(collectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { - // if (args.ContainsFileSystemEntryByName("tvshow.nfo")) - //{ - // return new Series - // { - // Path = args.Path, - // Name = Path.GetFileName(args.Path) - // }; - //} - var configuredContentType = _libraryManager.GetConfiguredContentType(args.Path); if (!string.Equals(configuredContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { @@ -100,7 +90,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - if (IsSeriesFolder(args.Path, args.FileSystemChildren, args.DirectoryService, _fileSystem, _logger, _libraryManager, false)) + if (IsSeriesFolder(args.Path, args.FileSystemChildren, _logger, _libraryManager, false)) { return new Series { @@ -117,8 +107,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV public static bool IsSeriesFolder( string path, IEnumerable<FileSystemMetadata> fileSystemChildren, - IDirectoryService directoryService, - IFileSystem fileSystem, ILogger<SeriesResolver> logger, ILibraryManager libraryManager, bool isTvContentType) @@ -127,7 +115,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { if (child.IsDirectory) { - if (IsSeasonFolder(child.FullName, isTvContentType, libraryManager)) + if (IsSeasonFolder(child.FullName, isTvContentType)) { logger.LogDebug("{Path} is a series because of season folder {Dir}.", path, child.FullName); return true; @@ -161,31 +149,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV } /// <summary> - /// Determines whether [is place holder] [the specified path]. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if [is place holder] [the specified path]; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">path</exception> - private static bool IsVideoPlaceHolder(string path) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - var extension = Path.GetExtension(path); - - return string.Equals(extension, ".disc", StringComparison.OrdinalIgnoreCase); - } - - /// <summary> /// Determines whether [is season folder] [the specified path]. /// </summary> /// <param name="path">The path.</param> /// <param name="isTvContentType">if set to <c>true</c> [is tv content type].</param> - /// <param name="libraryManager">The library manager.</param> /// <returns><c>true</c> if [is season folder] [the specified path]; otherwise, <c>false</c>.</returns> - private static bool IsSeasonFolder(string path, bool isTvContentType, ILibraryManager libraryManager) + private static bool IsSeasonFolder(string path, bool isTvContentType) { var seasonNumber = SeasonPathParser.Parse(path, isTvContentType, isTvContentType).SeasonNumber; diff --git a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs index 62268fce9..9599faea4 100644 --- a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using MediaBrowser.Controller.Entities; diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index 9a69bce0e..9d0a24a88 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -1,18 +1,19 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Linq; +using Diacritics.Extensions; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Search; -using Microsoft.Extensions.Logging; using Genre = MediaBrowser.Controller.Entities.Genre; using Person = MediaBrowser.Controller.Entities.Person; @@ -47,7 +48,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> @@ -87,61 +88,61 @@ namespace Emby.Server.Implementations.Library var excludeItemTypes = query.ExcludeItemTypes.ToList(); var includeItemTypes = (query.IncludeItemTypes ?? Array.Empty<string>()).ToList(); - excludeItemTypes.Add(typeof(Year).Name); - excludeItemTypes.Add(typeof(Folder).Name); + excludeItemTypes.Add(nameof(Year)); + excludeItemTypes.Add(nameof(Folder)); if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Genre", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(Genre).Name); - AddIfMissing(includeItemTypes, typeof(MusicGenre).Name); + AddIfMissing(includeItemTypes, nameof(Genre)); + AddIfMissing(includeItemTypes, nameof(MusicGenre)); } } else { - AddIfMissing(excludeItemTypes, typeof(Genre).Name); - AddIfMissing(excludeItemTypes, typeof(MusicGenre).Name); + AddIfMissing(excludeItemTypes, nameof(Genre)); + AddIfMissing(excludeItemTypes, nameof(MusicGenre)); } if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains("People", StringComparer.OrdinalIgnoreCase) || includeItemTypes.Contains("Person", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(Person).Name); + AddIfMissing(includeItemTypes, nameof(Person)); } } else { - AddIfMissing(excludeItemTypes, typeof(Person).Name); + AddIfMissing(excludeItemTypes, nameof(Person)); } if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains("Studio", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(Studio).Name); + AddIfMissing(includeItemTypes, nameof(Studio)); } } else { - AddIfMissing(excludeItemTypes, typeof(Studio).Name); + AddIfMissing(excludeItemTypes, nameof(Studio)); } if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase))) { if (!query.IncludeMedia) { - AddIfMissing(includeItemTypes, typeof(MusicArtist).Name); + AddIfMissing(includeItemTypes, nameof(MusicArtist)); } } else { - AddIfMissing(excludeItemTypes, typeof(MusicArtist).Name); + AddIfMissing(excludeItemTypes, nameof(MusicArtist)); } - AddIfMissing(excludeItemTypes, typeof(CollectionFolder).Name); - AddIfMissing(excludeItemTypes, typeof(Folder).Name); + AddIfMissing(excludeItemTypes, nameof(CollectionFolder)); + AddIfMissing(excludeItemTypes, nameof(Folder)); var mediaTypes = query.MediaTypes.ToList(); if (includeItemTypes.Count > 0) @@ -156,8 +157,8 @@ namespace Emby.Server.Implementations.Library ExcludeItemTypes = excludeItemTypes.ToArray(), IncludeItemTypes = includeItemTypes.ToArray(), Limit = query.Limit, - IncludeItemsByName = string.IsNullOrEmpty(query.ParentId), - ParentId = string.IsNullOrEmpty(query.ParentId) ? Guid.Empty : new Guid(query.ParentId), + IncludeItemsByName = !query.ParentId.HasValue, + ParentId = query.ParentId ?? Guid.Empty, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, Recursive = true, diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index f9e5e6bbc..8aa605a90 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -13,6 +15,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using AudioBook = MediaBrowser.Controller.Entities.AudioBook; using Book = MediaBrowser.Controller.Entities.Book; namespace Emby.Server.Implementations.Library @@ -219,7 +222,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 not AudioBook && item is not Book) { var pctIn = decimal.Divide(positionTicks, runtimeTicks) * 100; @@ -238,13 +241,30 @@ namespace Emby.Server.Implementations.Library { // Enforce MinResumeDuration var durationSeconds = TimeSpan.FromTicks(runtimeTicks).TotalSeconds; - if (durationSeconds < _config.Configuration.MinResumeDurationSeconds && !(item is Book)) + if (durationSeconds < _config.Configuration.MinResumeDurationSeconds) { positionTicks = 0; data.Played = playedToCompletion = true; } } } + else if (positionTicks > 0 && hasRuntime && item is AudioBook) + { + var playbackPositionInMinutes = TimeSpan.FromTicks(positionTicks).TotalMinutes; + var remainingTimeInMinutes = TimeSpan.FromTicks(runtimeTicks - positionTicks).TotalMinutes; + + if (playbackPositionInMinutes < _config.Configuration.MinAudiobookResume) + { + // ignore progress during the beginning + positionTicks = 0; + } + else if (remainingTimeInMinutes < _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..e2da672a3 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -1,8 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using Jellyfin.Data.Entities; @@ -129,23 +130,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 +281,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/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index d4c8c35e6..f9a3e2c64 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -81,7 +81,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(MusicArtist).Name }, + IncludeItemTypes = new[] { nameof(MusicArtist) }, IsDeadArtist = true, IsLocked = false }).Cast<MusicArtist>().ToList(); diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index 8275c873a..8739a9e1b 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Person).Name }, + IncludeItemTypes = new[] { nameof(Person) }, IsDeadPerson = true, IsLocked = false }); diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index ca35adfff..9a8c5f39d 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -80,7 +80,7 @@ namespace Emby.Server.Implementations.Library.Validators var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Studio).Name }, + IncludeItemTypes = new[] { nameof(Studio) }, IsDeadStudio = true, IsLocked = false }); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index 2e13a3bb3..3fcadf5b1 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -16,13 +18,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public class DirectRecorder : IRecorder { private readonly ILogger _logger; - private readonly IHttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly IStreamHelper _streamHelper; - public DirectRecorder(ILogger logger, IHttpClient httpClient, IStreamHelper streamHelper) + public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) { _logger = logger; - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _streamHelper = streamHelper; } @@ -45,17 +47,18 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); - using (var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.None)) { onStarted(); _logger.LogInformation("Copying recording stream to file {0}", targetFile); // The media source is infinite so we need to handle stopping ourselves - var durationToken = new CancellationTokenSource(duration); - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - await directStreamProvider.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + await directStreamProvider.CopyToAsync(output, cancellationTokenSource.Token).ConfigureAwait(false); } _logger.LogInformation("Recording completed to file {0}", targetFile); @@ -63,37 +66,30 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { - var httpRequestOptions = new HttpRequestOptions - { - Url = mediaSource.Path, - BufferContent = false, - - // Some remote urls will expect a user agent to be supplied - UserAgent = "Emby/3.0", + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - // Shouldn't matter but may cause issues - DecompressionMethod = CompressionMethods.None - }; + _logger.LogInformation("Opened recording stream from tuner provider"); - using (var response = await _httpClient.SendAsync(httpRequestOptions, HttpMethod.Get).ConfigureAwait(false)) - { - _logger.LogInformation("Opened recording stream from tuner provider"); + Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + await using var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.None); - using (var output = new FileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.Read)) - { - onStarted(); + onStarted(); - _logger.LogInformation("Copying recording stream to file {0}", targetFile); + _logger.LogInformation("Copying recording stream to file {0}", targetFile); - // The media source if infinite so we need to handle stopping ourselves - var durationToken = new CancellationTokenSource(duration); - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + // The media source if infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + cancellationToken = linkedCancellationToken.Token; - await _streamHelper.CopyUntilCancelled(response.Content, output, 81920, cancellationToken).ConfigureAwait(false); - } - } + await _streamHelper.CopyUntilCancelled( + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + output, + IODefaults.CopyToBufferSize, + cancellationToken).ConfigureAwait(false); _logger.LogInformation("Recording completed to file {0}", targetFile); } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 09c52d95b..797063120 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,6 +9,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -16,7 +19,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -35,7 +37,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 @@ -48,9 +49,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly IServerApplicationHost _appHost; private readonly ILogger<EmbyTV> _logger; - private readonly IHttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; - private readonly IJsonSerializer _jsonSerializer; private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; private readonly TimerManager _timerProvider; @@ -80,8 +80,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV IStreamHelper streamHelper, IMediaSourceManager mediaSourceManager, ILogger<EmbyTV> logger, - IJsonSerializer jsonSerializer, - IHttpClient httpClient, + IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ILiveTvManager liveTvManager, IFileSystem fileSystem, @@ -94,7 +93,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _appHost = appHost; _logger = logger; - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _config = config; _fileSystem = fileSystem; _libraryManager = libraryManager; @@ -102,12 +101,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; @@ -604,11 +602,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return Task.CompletedTask; } - public Task DeleteRecordingAsync(string recordingId, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) { throw new NotImplementedException(); @@ -808,29 +801,24 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return null; } - public IEnumerable<ActiveRecordingInfo> GetAllActiveRecordings() - { - return _activeRecordings.Values.Where(i => i.Timer.Status == RecordingStatus.InProgress && !i.CancellationTokenSource.IsCancellationRequested); - } - public ActiveRecordingInfo GetActiveRecordingInfo(string path) { - if (string.IsNullOrWhiteSpace(path)) + if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) { return null; } - foreach (var recording in _activeRecordings.Values) + foreach (var (_, recordingInfo) in _activeRecordings) { - if (string.Equals(recording.Path, path, StringComparison.Ordinal) && !recording.CancellationTokenSource.IsCancellationRequested) + if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) { - var timer = recording.Timer; + var timer = recordingInfo.Timer; if (timer.Status != RecordingStatus.InProgress) { return null; } - return recording; + return recordingInfo; } } @@ -1015,16 +1003,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new Exception("Tuner not found."); } - private MediaSourceInfo CloneMediaSource(MediaSourceInfo mediaSource, bool enableStreamSharing) - { - var json = _jsonSerializer.SerializeToString(mediaSource); - mediaSource = _jsonSerializer.DeserializeFromString<MediaSourceInfo>(json); - - mediaSource.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + "_" + mediaSource.Id; - - return mediaSource; - } - public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(channelId)) @@ -1071,7 +1049,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> @@ -1645,19 +1623,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } return _activeRecordings - .Values - .ToList() - .Any(i => string.Equals(i.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); + .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); } private IRecorder GetRecorder(MediaSourceInfo mediaSource) { 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, _httpClient, _streamHelper); + return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); } private void OnSuccessfulRecording(TimerInfo timer, string path) @@ -1809,7 +1785,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, Limit = 1, ExternalId = timer.ProgramId, DtoOptions = new DtoOptions(true) @@ -1879,7 +1855,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { @@ -1943,7 +1920,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.Read)) + // use FileShare.None as this bypasses dotnet bug dotnet/runtime#42790 . + using (var stream = new FileStream(nfoPath, FileMode.Create, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { @@ -2170,7 +2148,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var query = new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, Limit = 1, DtoOptions = new DtoOptions(true) { @@ -2261,14 +2239,10 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var enabledTimersForSeries = new List<TimerInfo>(); foreach (var timer in allTimers) { - var existingTimer = _timerProvider.GetTimer(timer.Id); - - if (existingTimer == null) - { - existingTimer = string.IsNullOrWhiteSpace(timer.ProgramId) + var existingTimer = _timerProvider.GetTimer(timer.Id) + ?? (string.IsNullOrWhiteSpace(timer.ProgramId) ? null - : _timerProvider.GetTimerByProgramId(timer.ProgramId); - } + : _timerProvider.GetTimerByProgramId(timer.ProgramId)); if (existingTimer == null) { @@ -2389,7 +2363,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var query = new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, ExternalSeriesId = seriesTimer.SeriesId, DtoOptions = new DtoOptions(true) { @@ -2424,7 +2398,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV channel = _libraryManager.GetItemList( new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, ItemIds = new[] { parent.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; @@ -2483,7 +2457,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV channel = _libraryManager.GetItemList( new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, ItemIds = new[] { programInfo.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; @@ -2548,7 +2522,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var seriesIds = _libraryManager.GetItemIds( new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Series).Name }, + IncludeItemTypes = new[] { nameof(Series) }, Name = program.Name }).ToArray(); @@ -2561,7 +2535,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var result = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, ParentIndexNumber = program.SeasonNumber.Value, IndexNumber = program.EpisodeNumber.Value, AncestorIds = seriesIds, @@ -2627,7 +2601,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Locations = new string[] { customPath }, Name = "Recorded Movies", - CollectionType = CollectionType.Movies + CollectionType = CollectionTypeOptions.Movies }; } @@ -2638,7 +2612,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Locations = new string[] { customPath }, Name = "Recorded Shows", - CollectionType = CollectionType.TvShows + CollectionType = CollectionTypeOptions.TvShows }; } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index 612dc5238..93781cb7b 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -6,17 +8,18 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -26,26 +29,24 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly ILogger _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IServerApplicationPaths _appPaths; + private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private bool _hasExited; private Stream _logFileStream; private string _targetPath; private Process _process; - private readonly IJsonSerializer _json; - private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); - private readonly IServerConfigurationManager _config; public EncodedRecorder( ILogger logger, IMediaEncoder mediaEncoder, IServerApplicationPaths appPaths, - IJsonSerializer json, - IServerConfigurationManager config) + IServerConfigurationManager serverConfigurationManager) { _logger = logger; _mediaEncoder = mediaEncoder; _appPaths = appPaths; - _json = json; - _config = config; + _serverConfigurationManager = serverConfigurationManager; } private static bool CopySubtitles => false; @@ -58,20 +59,15 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { // The media source is infinite so we need to handle stopping ourselves - var durationToken = new CancellationTokenSource(duration); - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - await RecordFromFile(mediaSource, mediaSource.Path, targetFile, duration, onStarted, cancellationToken).ConfigureAwait(false); + await RecordFromFile(mediaSource, mediaSource.Path, targetFile, duration, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); _logger.LogInformation("Recording completed to file {0}", targetFile); } - private EncodingOptions GetEncodingOptions() - { - return _config.GetConfiguration<EncodingOptions>("encoding"); - } - - 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)); @@ -100,15 +96,15 @@ 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 { StartInfo = processStartInfo, EnableRaisingEvents = true }; - _process.Exited += (sender, args) => OnFfMpegProcessExited(_process, inputFile); + _process.Exited += (sender, args) => OnFfMpegProcessExited(_process); _process.Start(); @@ -120,8 +116,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) @@ -189,15 +183,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var outputParam = string.Empty; + var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); var commandLineArgs = string.Format( CultureInfo.InvariantCulture, - "-i \"{0}\" {2} -map_metadata -1 -threads 0 {3}{4}{5} -y \"{1}\"", + "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", inputTempFile, targetFile, videoArgs, GetAudioArgs(mediaSource), subtitleArgs, - outputParam); + outputParam, + threads); return inputModifier + " " + commandLineArgs; } @@ -221,20 +217,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } protected string GetOutputSizeParam() - { - var filters = new List<string>(); - - filters.Add("yadif=0:-1:0"); - - var output = string.Empty; - - if (filters.Count > 0) - { - output += string.Format(CultureInfo.InvariantCulture, " -vf \"{0}\"", string.Join(",", filters.ToArray())); - } - - return output; - } + => "-vf \"yadif=0:-1:0\""; private void Stop() { @@ -291,7 +274,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV /// <summary> /// Processes the exited. /// </summary> - private void OnFfMpegProcessExited(Process process, string inputFile) + private void OnFfMpegProcessExited(Process process) { using (process) { @@ -327,13 +310,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { using (var reader = new StreamReader(source)) { - while (!reader.EndOfStream) + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - var line = await reader.ReadLineAsync().ConfigureAwait(false); - var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); - await target.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); await target.FlushAsync().ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs index 463d0ed0a..0ec52a959 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs @@ -6,58 +6,46 @@ using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - internal class EpgChannelData { + + private readonly Dictionary<string, ChannelInfo> _channelsById; + + private readonly Dictionary<string, ChannelInfo> _channelsByNumber; + + private readonly Dictionary<string, ChannelInfo> _channelsByName; + public EpgChannelData(IEnumerable<ChannelInfo> channels) { - ChannelsById = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); - ChannelsByNumber = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); - ChannelsByName = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); + _channelsById = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); + _channelsByNumber = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); + _channelsByName = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase); foreach (var channel in channels) { - ChannelsById[channel.Id] = channel; + _channelsById[channel.Id] = channel; if (!string.IsNullOrEmpty(channel.Number)) { - ChannelsByNumber[channel.Number] = channel; + _channelsByNumber[channel.Number] = channel; } var normalizedName = NormalizeName(channel.Name ?? string.Empty); if (!string.IsNullOrWhiteSpace(normalizedName)) { - ChannelsByName[normalizedName] = channel; + _channelsByName[normalizedName] = channel; } } } - private Dictionary<string, ChannelInfo> ChannelsById { get; set; } + public ChannelInfo? GetChannelById(string id) + => _channelsById.GetValueOrDefault(id); - private Dictionary<string, ChannelInfo> ChannelsByNumber { get; set; } + public ChannelInfo? GetChannelByNumber(string number) + => _channelsByNumber.GetValueOrDefault(number); - private Dictionary<string, ChannelInfo> ChannelsByName { get; set; } - - public ChannelInfo GetChannelById(string id) - { - ChannelsById.TryGetValue(id, out var result); - - return result; - } - - public ChannelInfo GetChannelByNumber(string number) - { - ChannelsByNumber.TryGetValue(number, out var result); - - return result; - } - - public ChannelInfo GetChannelByName(string name) - { - ChannelsByName.TryGetValue(name, out var result); - - return result; - } + public ChannelInfo? GetChannelByName(string name) + => _channelsByName.GetValueOrDefault(name); public static string NormalizeName(string value) { diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs index fc543dc55..4a031e475 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -1,10 +1,13 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; using System.Linq; -using MediaBrowser.Model.Serialization; +using System.Text.Json; +using Jellyfin.Extensions.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.Options; 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,10 +47,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { - _items = _jsonSerializer.DeserializeFromFile<T[]>(_dataPath); + var bytes = File.ReadAllBytes(_dataPath); + _items = JsonSerializer.Deserialize<T[]>(bytes, _jsonOptions); return; } - catch (Exception ex) + catch (JsonException ex) { Logger.LogError(ex, "Error deserializing {Path}", _dataPath); } @@ -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/RecordingHelper.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs index 142c59542..32245f899 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs @@ -6,7 +6,7 @@ using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - internal class RecordingHelper + internal static class RecordingHelper { public static DateTime GetStartTime(TimerInfo timer) { @@ -70,17 +70,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private static string GetDateString(DateTime date) { - date = date.ToLocalTime(); - - return string.Format( - CultureInfo.InvariantCulture, - "{0}_{1}_{2}_{3}_{4}_{5}", - date.Year.ToString("0000", CultureInfo.InvariantCulture), - date.Month.ToString("00", CultureInfo.InvariantCulture), - date.Day.ToString("00", CultureInfo.InvariantCulture), - date.Hour.ToString("00", CultureInfo.InvariantCulture), - date.Minute.ToString("00", CultureInfo.InvariantCulture), - date.Second.ToString("00", CultureInfo.InvariantCulture)); + return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); } } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs index 194e4606d..b1259de23 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs @@ -2,15 +2,14 @@ using System; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; 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..6c52a9a73 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -8,7 +10,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 +18,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 c4d5cc58a..b7639a51c 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -1,51 +1,57 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; 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 Jellyfin.Extensions.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.Net; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.Listings { public class SchedulesDirect : IListingsProvider { + private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + private readonly ILogger<SchedulesDirect> _logger; - private readonly IJsonSerializer _jsonSerializer; - private readonly IHttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); private readonly IApplicationHost _appHost; + private readonly ICryptoProvider _cryptoProvider; - private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private DateTime _lastErrorResponse; public SchedulesDirect( ILogger<SchedulesDirect> logger, - IJsonSerializer jsonSerializer, - IHttpClient httpClient, - IApplicationHost appHost) + IHttpClientFactory httpClientFactory, + IApplicationHost appHost, + ICryptoProvider cryptoProvider) { _logger = logger; - _jsonSerializer = jsonSerializer; - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _appHost = appHost; + _cryptoProvider = cryptoProvider; } - private string UserAgent => _appHost.ApplicationUserAgent; - /// <inheritdoc /> public string Name => "Schedules Direct"; @@ -61,7 +67,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings while (start <= end) { - dates.Add(start.ToString("yyyy-MM-dd")); + dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); start = start.AddDays(1); } @@ -99,104 +105,86 @@ 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); - var httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/schedules", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - RequestContent = requestString - }; - - httpOptions.RequestHeaders["token"] = token; - - using (var response = await Post(httpOptions, true, info).ConfigureAwait(false)) - { - var dailySchedules = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Day>>(response.Content).ConfigureAwait(false); - _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); + options.Content = new StringContent(requestString, Encoding.UTF8, MediaTypeNames.Application.Json); + 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.DeserializeAsync<List<ScheduleDirect.Day>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); - httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/programs", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true - }; + using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); + programRequestOptions.Headers.TryAddWithoutValidation("token", token); - httpOptions.RequestHeaders["token"] = token; + var programsID = dailySchedules.SelectMany(d => d.programs.Select(s => s.programID)).Distinct(); + programRequestOptions.Content = new StringContent("[\"" + string.Join("\", \"", programsID) + "\"]", Encoding.UTF8, MediaTypeNames.Application.Json); - var programsID = dailySchedules.SelectMany(d => d.programs.Select(s => s.programID)).Distinct(); - httpOptions.RequestContent = "[\"" + string.Join("\", \"", programsID) + "\"]"; + 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.DeserializeAsync<List<ScheduleDirect.ProgramDetails>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var programDict = programDetails.ToDictionary(p => p.programID, y => y); - using (var innerResponse = await Post(httpOptions, true, info).ConfigureAwait(false)) - { - var programDetails = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ProgramDetails>>(innerResponse.Content).ConfigureAwait(false); - var programDict = programDetails.ToDictionary(p => p.programID, y => y); + var programIdsWithImages = programDetails + .Where(p => p.hasImageArtwork).Select(p => p.programID) + .ToList(); - var programIdsWithImages = - programDetails.Where(p => p.hasImageArtwork).Select(p => p.programID) - .ToList(); + var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); - var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); + var programsInfo = new List<ProgramInfo>(); + foreach (ScheduleDirect.Program schedule in dailySchedules.SelectMany(d => d.programs)) + { + // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + + // " which corresponds to channel " + channelNumber + " and program id " + + // schedule.programID + " which says it has images? " + + // programDict[schedule.programID].hasImageArtwork); - var programsInfo = new List<ProgramInfo>(); - foreach (ScheduleDirect.Program schedule in dailySchedules.SelectMany(d => d.programs)) + if (images != null) + { + var imageIndex = images.FindIndex(i => i.programID == schedule.programID.Substring(0, 10)); + if (imageIndex > -1) { - // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + - // " which corresponds to channel " + channelNumber + " and program id " + - // schedule.programID + " which says it has images? " + - // programDict[schedule.programID].hasImageArtwork); - - if (images != null) - { - var imageIndex = images.FindIndex(i => i.programID == schedule.programID.Substring(0, 10)); - if (imageIndex > -1) - { - var programEntry = programDict[schedule.programID]; + var programEntry = programDict[schedule.programID]; - var allImages = images[imageIndex].data ?? new List<ScheduleDirect.ImageData>(); - var imagesWithText = allImages.Where(i => string.Equals(i.text, "yes", StringComparison.OrdinalIgnoreCase)); - var imagesWithoutText = allImages.Where(i => string.Equals(i.text, "no", StringComparison.OrdinalIgnoreCase)); + var allImages = images[imageIndex].data ?? new List<ScheduleDirect.ImageData>(); + var imagesWithText = allImages.Where(i => string.Equals(i.text, "yes", StringComparison.OrdinalIgnoreCase)); + var imagesWithoutText = allImages.Where(i => string.Equals(i.text, "no", StringComparison.OrdinalIgnoreCase)); - const double DesiredAspect = 2.0 / 3; + const double DesiredAspect = 2.0 / 3; - programEntry.primaryImage = GetProgramImage(ApiUrl, imagesWithText, true, DesiredAspect) ?? - GetProgramImage(ApiUrl, allImages, true, DesiredAspect); + programEntry.primaryImage = GetProgramImage(ApiUrl, imagesWithText, true, DesiredAspect) ?? + GetProgramImage(ApiUrl, allImages, true, DesiredAspect); - const double WideAspect = 16.0 / 9; + const double WideAspect = 16.0 / 9; - programEntry.thumbImage = GetProgramImage(ApiUrl, imagesWithText, true, WideAspect); + programEntry.thumbImage = GetProgramImage(ApiUrl, imagesWithText, true, WideAspect); - // Don't supply the same image twice - if (string.Equals(programEntry.primaryImage, programEntry.thumbImage, StringComparison.Ordinal)) - { - programEntry.thumbImage = null; - } - - programEntry.backdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, WideAspect); - - // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LOT", false); - } + // Don't supply the same image twice + if (string.Equals(programEntry.primaryImage, programEntry.thumbImage, StringComparison.Ordinal)) + { + programEntry.thumbImage = null; } - programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.programID])); - } + programEntry.backdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, WideAspect); - return programsInfo; + // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LOT", false); + } } + + programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.programID])); } + + return programsInfo; } private static int GetSizeOrder(ScheduleDirect.ImageData image) { - if (!string.IsNullOrWhiteSpace(image.height) - && int.TryParse(image.height, out int value)) + if (int.TryParse(image.height, out int value)) { return value; } @@ -272,7 +260,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings Id = newID, StartDate = startAt, EndDate = endAt, - Name = details.titles[0].title120 ?? "Unkown", + Name = details.titles[0].title120 ?? "Unknown", OfficialRating = null, CommunityRating = null, EpisodeTitle = episodeTitle, @@ -318,7 +306,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings if (details.contentRating != null && details.contentRating.Count > 0) { - info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-").Replace("--", "-"); + info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-", StringComparison.Ordinal) + .Replace("--", "-", StringComparison.Ordinal); var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase)) @@ -367,13 +356,14 @@ namespace Emby.Server.Implementations.LiveTv.Listings if (!string.IsNullOrWhiteSpace(details.originalAirDate)) { - info.OriginalAirDate = DateTime.Parse(details.originalAirDate); + info.OriginalAirDate = DateTime.Parse(details.originalAirDate, CultureInfo.InvariantCulture); info.ProductionYear = info.OriginalAirDate.Value.Year; } if (details.movie != null) { - if (!string.IsNullOrEmpty(details.movie.year) && int.TryParse(details.movie.year, out int year)) + if (!string.IsNullOrEmpty(details.movie.year) + && int.TryParse(details.movie.year, out int year)) { info.ProductionYear = year; } @@ -460,7 +450,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms( ListingsProviderInfo info, - List<string> programIds, + IReadOnlyList<string> programIds, CancellationToken cancellationToken) { if (programIds.Count == 0) @@ -468,36 +458,28 @@ namespace Emby.Server.Implementations.LiveTv.Listings return new List<ScheduleDirect.ShowImages>(); } - var imageIdString = "["; - - foreach (var i in programIds) + StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); + foreach (ReadOnlySpan<char> i in programIds) { - var imageId = i.Substring(0, 10); - - if (!imageIdString.Contains(imageId, StringComparison.Ordinal)) - { - imageIdString += "\"" + imageId + "\","; - } + str.Append('"') + .Append(i.Slice(0, 10)) + .Append("\","); } - imageIdString = imageIdString.TrimEnd(',') + "]"; + // Remove last , + str.Length--; + str.Append(']'); - var httpOptions = new HttpRequestOptions() + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") { - Url = ApiUrl + "/metadata/programs", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - RequestContent = imageIdString, - LogErrorResponseBody = true, + Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) }; try { - using (var innerResponse2 = await Post(httpOptions, true, info).ConfigureAwait(false)) - { - return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>( - innerResponse2.Content).ConfigureAwait(false); - } + 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.DeserializeAsync<List<ScheduleDirect.ShowImages>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -518,41 +500,33 @@ namespace Emby.Server.Implementations.LiveTv.Listings return lineups; } - var options = new HttpRequestOptions() - { - Url = ApiUrl + "/headends?country=" + country + "&postalcode=" + location, - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true - }; - - options.RequestHeaders["token"] = token; + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); + options.Headers.TryAddWithoutValidation("token", token); try { - using (var httpResponse = await Get(options, false, info).ConfigureAwait(false)) - using (Stream responce = httpResponse.Content) - { - var root = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Headends>>(responce).ConfigureAwait(false); + 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.DeserializeAsync<List<ScheduleDirect.Headends>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (root != null) + if (root != null) + { + foreach (ScheduleDirect.Headends headend in root) { - foreach (ScheduleDirect.Headends headend in root) + foreach (ScheduleDirect.Lineup lineup in headend.lineups) { - foreach (ScheduleDirect.Lineup lineup in headend.lineups) + lineups.Add(new NameIdPair { - lineups.Add(new NameIdPair - { - Name = string.IsNullOrWhiteSpace(lineup.name) ? lineup.lineup : lineup.name, - Id = lineup.uri.Substring(18) - }); - } + Name = string.IsNullOrWhiteSpace(lineup.name) ? lineup.lineup : lineup.name, + Id = lineup.uri.Substring(18) + }); } } - else - { - _logger.LogInformation("No lineups available"); - } + } + else + { + _logger.LogInformation("No lineups available"); } } catch (Exception ex) @@ -563,8 +537,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings return lineups; } - private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); - private DateTime _lastErrorResponse; private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) { var username = info.Username; @@ -587,8 +559,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return null; } - NameValuePair savedToken = null; - if (!_tokens.TryGetValue(username, out savedToken)) + if (!_tokens.TryGetValue(username, out NameValuePair savedToken)) { savedToken = new NameValuePair(); _tokens.TryAdd(username, savedToken); @@ -614,7 +585,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); return result; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue) { @@ -633,91 +604,59 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - private async Task<HttpResponseInfo> Post(HttpRequestOptions options, + private async Task<HttpResponseMessage> Send( + HttpRequestMessage options, bool enableRetry, - ListingsProviderInfo providerInfo) + ListingsProviderInfo providerInfo, + CancellationToken cancellationToken, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { - // Schedules direct requires that the client support compression and will return a 400 response without it - options.DecompressionMethod = CompressionMethods.Deflate; - - try + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { - return await _httpClient.Post(options).ConfigureAwait(false); + return response; } - catch (HttpException ex) - { - _tokens.Clear(); - if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) - { - enableRetry = false; - } - - if (!enableRetry) - { - throw; - } - } - - options.RequestHeaders["token"] = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false); - return await Post(options, false, providerInfo).ConfigureAwait(false); - } - - private async Task<HttpResponseInfo> Get(HttpRequestOptions options, - bool enableRetry, - ListingsProviderInfo providerInfo) - { - // Schedules direct requires that the client support compression and will return a 400 response without it - options.DecompressionMethod = CompressionMethods.Deflate; - - try - { - return await _httpClient.SendAsync(options, HttpMethod.Get).ConfigureAwait(false); - } - catch (HttpException ex) + // Response is automatically disposed in the calling function, + // so dispose manually if not returning. + response.Dispose(); + if (!enableRetry || (int)response.StatusCode >= 500) { - _tokens.Clear(); - - if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) - { - enableRetry = false; - } - - if (!enableRetry) - { - throw; - } + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); } - options.RequestHeaders["token"] = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false); - return await Get(options, false, providerInfo).ConfigureAwait(false); + _tokens.Clear(); + options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); } - private async Task<string> GetTokenInternal(string username, string password, + private async Task<string> GetTokenInternal( + string username, + string password, CancellationToken cancellationToken) { - var httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/token", - UserAgent = UserAgent, - RequestContent = "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}", - CancellationToken = cancellationToken, - LogErrorResponseBody = true - }; - // _logger.LogInformation("Obtaining token from Schedules Direct from addres: " + httpOptions.Url + " with body " + - // httpOptions.RequestContent); + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); + var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>()); + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); + options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); - using (var response = await Post(httpOptions, false, null).ConfigureAwait(false)) + 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.DeserializeAsync<ScheduleDirect.Token>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(root.message, "OK", StringComparison.Ordinal)) { - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(response.Content).ConfigureAwait(false); - if (root.message == "OK") - { - _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token); - return root.token; - } - - throw new Exception("Could not authenticate with Schedules Direct Error: " + root.message); + _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token); + return root.token; } + + throw new Exception("Could not authenticate with Schedules Direct Error: " + root.message); } private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) @@ -736,20 +675,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings _logger.LogInformation("Adding new LineUp "); - var httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/lineups/" + info.ListingsId, - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - BufferContent = false - }; - - httpOptions.RequestHeaders["token"] = token; - - using (await _httpClient.SendAsync(httpOptions, HttpMethod.Put).ConfigureAwait(false)) - { - } + using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); + options.Headers.TryAddWithoutValidation("token", token); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); } private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) @@ -768,29 +696,22 @@ namespace Emby.Server.Implementations.LiveTv.Listings _logger.LogInformation("Headends on account "); - var options = new HttpRequestOptions() - { - Url = ApiUrl + "/lineups", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true - }; - - options.RequestHeaders["token"] = token; + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups"); + options.Headers.TryAddWithoutValidation("token", token); try { - using (var httpResponse = await Get(options, false, null).ConfigureAwait(false)) - using (var response = httpResponse.Content) - { - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(response).ConfigureAwait(false); + 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.DeserializeAsync<ScheduleDirect.Lineups>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase)); - } + return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase)); } - catch (HttpException ex) + 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; @@ -851,80 +772,46 @@ namespace Emby.Server.Implementations.LiveTv.Listings throw new Exception("token required"); } - var httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/lineups/" + listingsId, - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - }; + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); + options.Headers.TryAddWithoutValidation("token", token); - httpOptions.RequestHeaders["token"] = token; + 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.DeserializeAsync<ScheduleDirect.Channel>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count); + _logger.LogInformation("Mapping Stations to Channel"); - var list = new List<ChannelInfo>(); + var allStations = root.stations ?? new List<ScheduleDirect.Station>(); - using (var httpResponse = await Get(httpOptions, true, info).ConfigureAwait(false)) - using (var response = httpResponse.Content) + var map = root.map; + var list = new List<ChannelInfo>(map.Count); + foreach (var channel in map) { - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(response).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 ?? Enumerable.Empty<ScheduleDirect.Station>(); + var channelNumber = GetChannelNumber(channel); - foreach (ScheduleDirect.Map map in root.map) - { - var channelNumber = GetChannelNumber(map); - - var station = allStations.FirstOrDefault(item => string.Equals(item.stationID, map.stationID, StringComparison.OrdinalIgnoreCase)); - if (station == null) - { - station = new ScheduleDirect.Station - { - stationID = map.stationID - }; - } - - var channelInfo = new ChannelInfo + var station = allStations.Find(item => string.Equals(item.stationID, channel.stationID, StringComparison.OrdinalIgnoreCase)) + ?? new ScheduleDirect.Station { - Id = station.stationID, - CallSign = station.callsign, - Number = channelNumber, - Name = string.IsNullOrWhiteSpace(station.name) ? channelNumber : station.name + stationID = channel.stationID }; - if (station.logo != null) - { - channelInfo.ImageUrl = station.logo.URL; - } - - list.Add(channelInfo); - } - } - - return list; - } - - private ScheduleDirect.Station GetStation(List<ScheduleDirect.Station> allStations, string channelNumber, string channelName) - { - if (!string.IsNullOrWhiteSpace(channelName)) - { - channelName = NormalizeName(channelName); - - var result = allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.callsign ?? string.Empty), channelName, StringComparison.OrdinalIgnoreCase)); + var channelInfo = new ChannelInfo + { + Id = station.stationID, + CallSign = station.callsign, + Number = channelNumber, + Name = string.IsNullOrWhiteSpace(station.name) ? channelNumber : station.name + }; - if (result != null) + if (station.logo != null) { - return result; + channelInfo.ImageUrl = station.logo.URL; } - } - if (!string.IsNullOrWhiteSpace(channelNumber)) - { - return allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.stationID ?? string.Empty), channelNumber, StringComparison.OrdinalIgnoreCase)); + list.Add(channelInfo); } - return null; + return list; } private static string NormalizeName(string value) @@ -969,7 +856,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings public List<Lineup> lineups { get; set; } } - public class Headends { public string headend { get; set; } @@ -981,8 +867,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings public List<Lineup> lineups { get; set; } } - - public class Map { public string stationID { get; set; } @@ -1066,9 +950,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings public List<string> date { get; set; } } - - - public class Rating { public string body { get; set; } @@ -1112,8 +993,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings public string isPremiereOrFinale { get; set; } } - - public class MetadataSchedule { public string modified { get; set; } @@ -1141,7 +1020,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - // public class Title { public string title120 { get; set; } diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index f33d07174..ebad4eddf 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -1,10 +1,11 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Threading; @@ -25,20 +26,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings public class XmlTvListingsProvider : IListingsProvider { private readonly IServerConfigurationManager _config; - private readonly IHttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<XmlTvListingsProvider> _logger; private readonly IFileSystem _fileSystem; private readonly IZipClient _zipClient; public XmlTvListingsProvider( IServerConfigurationManager config, - IHttpClient httpClient, + IHttpClientFactory httpClientFactory, ILogger<XmlTvListingsProvider> logger, IFileSystem fileSystem, IZipClient zipClient) { _config = config; - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _logger = logger; _fileSystem = fileSystem; _zipClient = zipClient; @@ -78,28 +79,11 @@ namespace Emby.Server.Implementations.LiveTv.Listings Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); - using (var res = await _httpClient.SendAsync( - new HttpRequestOptions - { - CancellationToken = cancellationToken, - Url = path, - DecompressionMethod = CompressionMethods.Gzip, - }, - HttpMethod.Get).ConfigureAwait(false)) - using (var stream = res.Content) - using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew)) + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(path, cancellationToken).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew)) { - if (res.ContentHeaders.ContentEncoding.Contains("gzip")) - { - using (var gzStream = new GZipStream(stream, CompressionMode.Decompress)) - { - await gzStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - } - else - { - await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } return UnzipIfNeeded(path, cacheFile); diff --git a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs index ba916af38..098f193fb 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs @@ -1,21 +1,23 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.LiveTv; namespace Emby.Server.Implementations.LiveTv { + /// <summary> + /// <see cref="IConfigurationFactory" /> implementation for <see cref="LiveTvOptions" />. + /// </summary> public class LiveTvConfigurationFactory : IConfigurationFactory { + /// <inheritdoc /> public IEnumerable<ConfigurationStore> GetConfigurations() { return new ConfigurationStore[] { new ConfigurationStore { - ConfigurationType = typeof(LiveTvOptions), - Key = "livetv" + ConfigurationType = typeof(LiveTvOptions), + Key = "livetv" } }; } diff --git a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs index 49ad73af3..21e1409ac 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -159,7 +161,7 @@ namespace Emby.Server.Implementations.LiveTv { var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(Series).Name }, + IncludeItemTypes = new string[] { nameof(Series) }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Thumb }, @@ -253,7 +255,7 @@ namespace Emby.Server.Implementations.LiveTv { var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(Series).Name }, + IncludeItemTypes = new string[] { nameof(Series) }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Thumb }, @@ -296,7 +298,7 @@ namespace Emby.Server.Implementations.LiveTv var program = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(Series).Name }, + IncludeItemTypes = new string[] { nameof(Series) }, Name = seriesName, Limit = 1, ImageTypes = new ImageType[] { ImageType.Primary }, @@ -307,7 +309,7 @@ namespace Emby.Server.Implementations.LiveTv { program = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, ExternalSeriesId = programSeriesId, Limit = 1, ImageTypes = new ImageType[] { ImageType.Primary }, diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index a898a564f..d964769b5 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -187,7 +189,7 @@ namespace Emby.Server.Implementations.LiveTv IsKids = query.IsKids, IsSports = query.IsSports, IsSeries = query.IsSeries, - IncludeItemTypes = new[] { typeof(LiveTvChannel).Name }, + IncludeItemTypes = new[] { nameof(LiveTvChannel) }, TopParentIds = new[] { topFolder.Id }, IsFavorite = query.IsFavorite, IsLiked = query.IsLiked, @@ -808,7 +810,7 @@ namespace Emby.Server.Implementations.LiveTv var internalQuery = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, MinEndDate = query.MinEndDate, MinStartDate = query.MinStartDate, MaxEndDate = query.MaxEndDate, @@ -872,7 +874,7 @@ namespace Emby.Server.Implementations.LiveTv var internalQuery = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, IsAiring = query.IsAiring, HasAired = query.HasAired, IsNews = query.IsNews, @@ -987,10 +989,7 @@ namespace Emby.Server.Implementations.LiveTv var externalProgramId = programTuple.Item2; string externalSeriesId = programTuple.Item3; - if (timerList == null) - { - timerList = (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - } + timerList ??= (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase)); var foundSeriesTimer = false; @@ -1018,10 +1017,7 @@ namespace Emby.Server.Implementations.LiveTv continue; } - if (seriesTimerList == null) - { - seriesTimerList = (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - } + seriesTimerList ??= (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase)); @@ -1089,8 +1085,8 @@ namespace Emby.Server.Implementations.LiveTv if (cleanDatabase) { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { typeof(LiveTvChannel).Name }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { typeof(LiveTvProgram).Name }, progress, cancellationToken); + CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { nameof(LiveTvChannel) }, progress, cancellationToken); + CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { nameof(LiveTvProgram) }, progress, cancellationToken); } var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); @@ -1181,7 +1177,7 @@ namespace Emby.Server.Implementations.LiveTv var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new string[] { nameof(LiveTvProgram) }, ChannelIds = new Guid[] { currentChannel.Id }, DtoOptions = new DtoOptions(true) }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); @@ -1346,11 +1342,11 @@ namespace Emby.Server.Implementations.LiveTv { if (query.IsMovie.Value) { - includeItemTypes.Add(typeof(Movie).Name); + includeItemTypes.Add(nameof(Movie)); } else { - excludeItemTypes.Add(typeof(Movie).Name); + excludeItemTypes.Add(nameof(Movie)); } } @@ -1358,11 +1354,11 @@ namespace Emby.Server.Implementations.LiveTv { if (query.IsSeries.Value) { - includeItemTypes.Add(typeof(Episode).Name); + includeItemTypes.Add(nameof(Episode)); } else { - excludeItemTypes.Add(typeof(Episode).Name); + excludeItemTypes.Add(nameof(Episode)); } } @@ -1429,7 +1425,7 @@ namespace Emby.Server.Implementations.LiveTv return result; } - public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, ItemFields[] fields, User user = null) + public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, IReadOnlyList<ItemFields> fields, User user = null) { var programTuples = new List<Tuple<BaseItemDto, string, string>>(); var hasChannelImage = fields.Contains(ItemFields.ChannelImage); @@ -1883,7 +1879,7 @@ namespace Emby.Server.Implementations.LiveTv var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, ChannelIds = channelIds, MaxStartDate = now, MinEndDate = now, @@ -1928,7 +1924,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; } @@ -1974,10 +1970,7 @@ namespace Emby.Server.Implementations.LiveTv }; } - if (service == null) - { - service = _services[0]; - } + service ??= _services[0]; var info = await service.GetNewTimerDefaultsAsync(cancellationToken, programInfo).ConfigureAwait(false); @@ -2018,7 +2011,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; @@ -2135,6 +2128,7 @@ namespace Emby.Server.Implementations.LiveTv } private bool _disposed = false; + /// <summary> /// Releases unmanaged and - optionally - managed resources. /// </summary> @@ -2207,7 +2201,7 @@ namespace Emby.Server.Implementations.LiveTv /// <returns>Task.</returns> public Task ResetTuner(string id, CancellationToken cancellationToken) { - var parts = id.Split(new[] { '_' }, 2); + var parts = id.Split('_', 2); var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), parts[0], StringComparison.OrdinalIgnoreCase)); @@ -2238,7 +2232,7 @@ namespace Emby.Server.Implementations.LiveTv public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) { - info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.Serialize(info)); + info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.SerializeToUtf8Bytes(info)); var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); @@ -2272,7 +2266,7 @@ namespace Emby.Server.Implementations.LiveTv if (dataSourceChanged) { - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); } return info; @@ -2282,7 +2276,7 @@ namespace Emby.Server.Implementations.LiveTv { // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider - info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.Serialize(info)); + info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.SerializeToUtf8Bytes(info)); var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); @@ -2315,7 +2309,7 @@ namespace Emby.Server.Implementations.LiveTv _config.SaveConfiguration("livetv", config); - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); return info; } @@ -2327,7 +2321,7 @@ namespace Emby.Server.Implementations.LiveTv config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); _config.SaveConfiguration("livetv", config); - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); } public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelId, string providerChannelId) @@ -2361,7 +2355,7 @@ namespace Emby.Server.Implementations.LiveTv var tunerChannelMappings = tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(); - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelId, StringComparison.OrdinalIgnoreCase)); } diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs index 8a0c0043a..ecd28097d 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -76,7 +78,6 @@ namespace Emby.Server.Implementations.LiveTv } var list = sources.ToList(); - var serverUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); foreach (var source in list) { @@ -103,7 +104,7 @@ namespace Emby.Server.Implementations.LiveTv // Dummy this up so that direct play checks can still run if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http) { - source.Path = serverUrl; + source.Path = _appHost.GetSmartApiUrl(string.Empty); } } diff --git a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs b/Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs index f1b61f7c7..15df0dcf1 100644 --- a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs +++ b/Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs @@ -1,7 +1,6 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; @@ -10,40 +9,61 @@ using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.LiveTv { - public class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask + /// <summary> + /// The "Refresh Guide" scheduled task. + /// </summary> + public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask { private readonly ILiveTvManager _liveTvManager; private readonly IConfigurationManager _config; - public RefreshChannelsScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) + /// <summary> + /// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class. + /// </summary> + /// <param name="liveTvManager">The live tv manager.</param> + /// <param name="config">The configuration manager.</param> + public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) { _liveTvManager = liveTvManager; _config = config; } + /// <inheritdoc /> public string Name => "Refresh Guide"; + /// <inheritdoc /> public string Description => "Downloads channel information from live tv services."; + /// <inheritdoc /> public string Category => "Live TV"; - public Task Execute(System.Threading.CancellationToken cancellationToken, IProgress<double> progress) + /// <inheritdoc /> + public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <inheritdoc /> + public string Key => "RefreshGuide"; + + /// <inheritdoc /> + public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) { var manager = (LiveTvManager)_liveTvManager; return manager.RefreshChannels(progress, cancellationToken); } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] { // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks} + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } @@ -51,13 +71,5 @@ namespace Emby.Server.Implementations.LiveTv { return _config.GetConfiguration<LiveTvOptions>("livetv"); } - - public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0; - - public bool IsEnabled => true; - - public bool IsLogged => true; - - public string Key => "RefreshGuide"; } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index fbcd4ef37..5941613cf 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -38,6 +40,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public virtual bool IsSupported => true; protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); + public abstract string Type { get; } public async Task<List<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken) 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..0f0453189 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs @@ -0,0 +1,23 @@ +#nullable disable + +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..42068cd34 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs @@ -0,0 +1,42 @@ +#nullable disable + +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 2b5f69d41..011748d1d 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,14 +12,14 @@ using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -31,19 +33,21 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { public class HdHomerunHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost { - private readonly IHttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; private readonly ISocketFactory _socketFactory; private readonly INetworkManager _networkManager; private readonly IStreamHelper _streamHelper; + private readonly JsonSerializerOptions _jsonOptions; + private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>(); public HdHomerunHost( IServerConfigurationManager config, ILogger<HdHomerunHost> logger, IFileSystem fileSystem, - IHttpClient httpClient, + IHttpClientFactory httpClientFactory, IServerApplicationHost appHost, ISocketFactory socketFactory, INetworkManager networkManager, @@ -51,11 +55,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun IMemoryCache memoryCache) : base(config, logger, fileSystem, memoryCache) { - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _appHost = appHost; _socketFactory = socketFactory; _networkManager = networkManager; _streamHelper = streamHelper; + + _jsonOptions = JsonDefaults.Options; } public string Name => "HD Homerun"; @@ -67,20 +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); - var options = new HttpRequestOptions - { - Url = model.LineupURL, - CancellationToken = cancellationToken, - BufferContent = false - }; - - using var response = await _httpClient.SendAsync(options, HttpMethod.Get).ConfigureAwait(false); - await using var stream = response.Content; - var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, cancellationToken: cancellationToken) + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL ?? model.BaseURL + "/lineup.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, _jsonOptions, cancellationToken) .ConfigureAwait(false) ?? new List<Channels>(); if (info.ImportFavoritesOnly) @@ -107,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, @@ -116,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; @@ -133,15 +132,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun try { - using var response = await _httpClient.SendAsync( - new HttpRequestOptions - { - Url = string.Format(CultureInfo.InvariantCulture, "{0}/discover.json", GetApiUrl(info)), - CancellationToken = cancellationToken, - BufferContent = false - }, HttpMethod.Get).ConfigureAwait(false); - await using var stream = response.Content; - var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, cancellationToken: cancellationToken) + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .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, _jsonOptions, cancellationToken) .ConfigureAwait(false); if (!string.IsNullOrEmpty(cacheKey)) @@ -154,7 +150,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return discoverResponse; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) { @@ -183,48 +179,41 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - using (var response = await _httpClient.SendAsync( - new HttpRequestOptions() - { - Url = string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), - CancellationToken = cancellationToken, - BufferContent = false - }, - HttpMethod.Get).ConfigureAwait(false)) - using (var stream = response.Content) - using (var sr = new StreamReader(stream, System.Text.Encoding.UTF8)) - { - var tuners = new List<LiveTvTunerInfo>(); - while (!sr.EndOfStream) + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var sr = new StreamReader(stream, System.Text.Encoding.UTF8); + var tuners = new List<LiveTvTunerInfo>(); + await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false)) + { + string stripedLine = StripXML(line); + if (stripedLine.Contains("Channel", StringComparison.Ordinal)) { - string line = StripXML(sr.ReadLine()); - if (line.Contains("Channel", StringComparison.Ordinal)) + LiveTvTunerStatus status; + var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); + var name = stripedLine.Substring(0, index - 1); + var currentChannel = stripedLine.Substring(index + 7); + if (string.Equals(currentChannel, "none", StringComparison.Ordinal)) { - LiveTvTunerStatus status; - var index = line.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); - var name = line.Substring(0, index - 1); - var currentChannel = line.Substring(index + 7); - if (currentChannel != "none") - { - status = LiveTvTunerStatus.LiveTv; - } - else - { - status = LiveTvTunerStatus.Available; - } - - tuners.Add(new LiveTvTunerInfo - { - Name = name, - SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, - ProgramName = currentChannel, - Status = status - }); + status = LiveTvTunerStatus.LiveTv; + } + else + { + status = LiveTvTunerStatus.Available; } - } - return tuners; + tuners.Add(new LiveTvTunerInfo + { + Name = name, + SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, + ProgramName = currentChannel, + Status = status + }); + } } + + return tuners; } private static string StripXML(string source) @@ -255,8 +244,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun if (!inside) { - buffer[bufferIndex] = let; - bufferIndex++; + buffer[bufferIndex++] = let; } } @@ -347,30 +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"); - } - private static string GetHdHrIdFromChannelId(string channelId) { return channelId.Split('_')[1]; @@ -460,10 +424,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun string audioCodec = channelInfo.AudioCodec; - if (!videoBitrate.HasValue) - { - videoBitrate = isHd ? 15000000 : 2000000; - } + videoBitrate ??= isHd ? 15000000 : 2000000; int? audioBitrate = isHd ? 448000 : 192000; @@ -581,6 +542,19 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo info, ChannelInfo channelInfo, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { + var tunerCount = info.TunerCount; + + if (tunerCount > 0) + { + var tunerHostId = info.Id; + var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase)); + + if (liveStreams.Count() >= tunerCount) + { + throw new LiveTvConflictException("HDHomeRun simultaneous stream limit has been reached."); + } + } + var profile = streamId.Split('_')[0]; Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channelInfo.Id, streamId, profile); @@ -610,7 +584,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Logger, Config, _appHost, - _networkManager, _streamHelper); } @@ -634,7 +607,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun info, streamId, FileSystem, - _httpClient, + _httpClientFactory, Logger, Config, _appHost, @@ -651,7 +624,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Logger, Config, _appHost, - _networkManager, _streamHelper); } @@ -668,7 +640,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var modelInfo = await GetModelInfo(info, true, CancellationToken.None).ConfigureAwait(false); info.DeviceId = modelInfo.DeviceID; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound) { @@ -680,42 +652,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) @@ -723,7 +659,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _modelCache.Clear(); } - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(new CancellationTokenSource(discoveryDurationMs).Token, cancellationToken).Token; + using var timedCancellationToken = new CancellationTokenSource(discoveryDurationMs); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timedCancellationToken.Token, cancellationToken); + cancellationToken = linkedCancellationTokenSource.Token; var list = new List<TunerHostInfo>(); // Create udp broadcast discovery message @@ -768,7 +706,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 { @@ -780,6 +718,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/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index d4a88e299..3016eeda2 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -1,7 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Buffers; +using System.Buffers.Binary; using System.Collections.Generic; using System.Globalization; using System.Net; @@ -10,6 +13,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common; using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun @@ -26,7 +30,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public LegacyHdHomerunChannelCommands(string url) { // parse url for channel and program - var regExp = new Regex(@"\/ch(\d+)-?(\d*)"); + var regExp = new Regex(@"\/ch([0-9]+)-?([0-9]*)"); var match = regExp.Match(url); if (match.Success) { @@ -111,26 +115,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) { - using (var client = new TcpClient(new IPEndPoint(remoteIp, HdHomeRunPort))) - using (var stream = client.GetStream()) - { - return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); - } + using var client = new TcpClient(); + client.Connect(remoteIp, HdHomeRunPort); + + using var stream = client.GetStream(); + return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); } private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken) { - var lockkeyMsg = CreateGetMessage(tuner, "lockkey"); - await stream.WriteAsync(lockkeyMsg, 0, lockkeyMsg.Length, cancellationToken).ConfigureAwait(false); - byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); try { - int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var msgLen = WriteGetMessage(buffer, tuner, "lockkey"); + await stream.WriteAsync(buffer.AsMemory(0, msgLen), cancellationToken).ConfigureAwait(false); - ParseReturnMessage(buffer, receivedBytes, out string returnVal); + int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - return string.Equals(returnVal, "none", StringComparison.OrdinalIgnoreCase); + return VerifyReturnValueOfGetSet(buffer.AsSpan(receivedBytes), "none"); } finally { @@ -142,7 +144,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort); - _tcpClient = new TcpClient(_remoteEndPoint); + _tcpClient = new TcpClient(); + _tcpClient.Connect(_remoteEndPoint); if (!_lockkey.HasValue) { @@ -165,24 +168,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _activeTuner = i; var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue); - var lockkeyMsg = CreateSetMessage(i, "lockkey", lockKeyString, null); - await stream.WriteAsync(lockkeyMsg, 0, lockkeyMsg.Length, cancellationToken).ConfigureAwait(false); - int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var lockkeyMsgLen = WriteSetMessage(buffer, i, "lockkey", lockKeyString, null); + await stream.WriteAsync(buffer.AsMemory(0, lockkeyMsgLen), cancellationToken).ConfigureAwait(false); + int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { continue; } foreach (var command in commands.GetCommands()) { - var channelMsg = CreateSetMessage(i, command.Item1, command.Item2, lockKeyValue); - await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false); - receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var channelMsgLen = WriteSetMessage(buffer, i, command.Item1, command.Item2, lockKeyValue); + await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false); + receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false); continue; @@ -190,13 +193,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIp, localPort); - var targetMsg = CreateSetMessage(i, "target", targetValue, lockKeyValue); + var targetMsgLen = WriteSetMessage(buffer, i, "target", targetValue, lockKeyValue); - await stream.WriteAsync(targetMsg, 0, targetMsg.Length, cancellationToken).ConfigureAwait(false); - receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(buffer.AsMemory(0, targetMsgLen), cancellationToken).ConfigureAwait(false); + receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false); continue; @@ -221,30 +224,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return; } - using (var tcpClient = new TcpClient(_remoteEndPoint)) - using (var stream = tcpClient.GetStream()) + using var tcpClient = new TcpClient(); + tcpClient.Connect(_remoteEndPoint); + + using var stream = tcpClient.GetStream(); + var commandList = commands.GetCommands(); + byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); + try { - var commandList = commands.GetCommands(); - byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); - try + foreach (var command in commandList) { - foreach (var command in commandList) - { - var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey); - await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false); - int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + var channelMsgLen = WriteSetMessage(buffer, _activeTuner, command.Item1, command.Item2, _lockkey); + await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false); + int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - // parse response to make sure it worked - if (!ParseReturnMessage(buffer, receivedBytes, out _)) - { - return; - } + // parse response to make sure it worked + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) + { + return; } } - finally - { - ArrayPool<byte>.Shared.Return(buffer); - } + } + finally + { + ArrayPool<byte>.Shared.Return(buffer); } } @@ -264,17 +267,17 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { var stream = client.GetStream(); - var releaseTarget = CreateSetMessage(_activeTuner, "target", "none", lockKeyValue); - await stream.WriteAsync(releaseTarget, 0, releaseTarget.Length).ConfigureAwait(false); - var buffer = ArrayPool<byte>.Shared.Rent(8192); try { - await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); - var releaseKeyMsg = CreateSetMessage(_activeTuner, "lockkey", "none", lockKeyValue); + var releaseTargetLen = WriteSetMessage(buffer, _activeTuner, "target", "none", lockKeyValue); + await stream.WriteAsync(buffer.AsMemory(0, releaseTargetLen)).ConfigureAwait(false); + + await stream.ReadAsync(buffer).ConfigureAwait(false); + var releaseKeyMsgLen = WriteSetMessage(buffer, _activeTuner, "lockkey", "none", lockKeyValue); _lockkey = null; - await stream.WriteAsync(releaseKeyMsg, 0, releaseKeyMsg.Length).ConfigureAwait(false); - await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + await stream.WriteAsync(buffer.AsMemory(0, releaseKeyMsgLen)).ConfigureAwait(false); + await stream.ReadAsync(buffer).ConfigureAwait(false); } finally { @@ -282,249 +285,136 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - private static byte[] CreateGetMessage(int tuner, string name) + internal static int WriteGetMessage(Span<byte> buffer, int tuner, string name) { - var byteName = Encoding.UTF8.GetBytes(string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}\0", tuner, name)); - int messageLength = byteName.Length + 10; // 4 bytes for header + 4 bytes for crc + 2 bytes for tag name and length - - var message = new byte[messageLength]; + var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name); + int offset = WriteHeaderAndPayload(buffer, byteName); + return FinishPacket(buffer, offset); + } - int offset = InsertHeaderAndName(byteName, messageLength, message); + internal static int WriteSetMessage(Span<byte> buffer, int tuner, string name, string value, uint? lockkey) + { + var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name); + int offset = WriteHeaderAndPayload(buffer, byteName); - bool flipEndian = BitConverter.IsLittleEndian; + buffer[offset++] = GetSetValue; + offset += WriteNullTerminatedString(buffer.Slice(offset), value); - // calculate crc and insert at the end of the message - var crcBytes = BitConverter.GetBytes(HdHomerunCrc.GetCrc32(message, messageLength - 4)); - if (flipEndian) + if (lockkey.HasValue) { - Array.Reverse(crcBytes); + buffer[offset++] = GetSetLockkey; + buffer[offset++] = 4; + BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(offset), lockkey.Value); + offset += 4; } - Buffer.BlockCopy(crcBytes, 0, message, offset, 4); - - return message; + return FinishPacket(buffer, offset); } - private static byte[] CreateSetMessage(int tuner, string name, string value, uint? lockkey) + internal static int WriteNullTerminatedString(Span<byte> buffer, ReadOnlySpan<char> payload) { - var byteName = Encoding.UTF8.GetBytes(string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}\0", tuner, name)); - var byteValue = Encoding.UTF8.GetBytes(string.Format(CultureInfo.InvariantCulture, "{0}\0", value)); + int len = Encoding.UTF8.GetBytes(payload, buffer.Slice(1)) + 1; - int messageLength = byteName.Length + byteValue.Length + 12; - if (lockkey.HasValue) - { - messageLength += 6; - } + // TODO: variable length: this can be 2 bytes if len > 127 + // Write length in front of value + buffer[0] = Convert.ToByte(len); - var message = new byte[messageLength]; + // null-terminate + buffer[len++] = 0; - int offset = InsertHeaderAndName(byteName, messageLength, message); + return len; + } - bool flipEndian = BitConverter.IsLittleEndian; + private static int WriteHeaderAndPayload(Span<byte> buffer, ReadOnlySpan<char> payload) + { + // Packet type + BinaryPrimitives.WriteUInt16BigEndian(buffer, GetSetRequest); - message[offset++] = GetSetValue; - message[offset++] = Convert.ToByte(byteValue.Length); - Buffer.BlockCopy(byteValue, 0, message, offset, byteValue.Length); - offset += byteValue.Length; - if (lockkey.HasValue) - { - message[offset++] = GetSetLockkey; - message[offset++] = 4; - var lockKeyBytes = BitConverter.GetBytes(lockkey.Value); - if (flipEndian) - { - Array.Reverse(lockKeyBytes); - } + // We write the payload length at the end + int offset = 4; - Buffer.BlockCopy(lockKeyBytes, 0, message, offset, 4); - offset += 4; - } - - // calculate crc and insert at the end of the message - var crcBytes = BitConverter.GetBytes(HdHomerunCrc.GetCrc32(message, messageLength - 4)); - if (flipEndian) - { - Array.Reverse(crcBytes); - } + // Tag + buffer[offset++] = GetSetName; - Buffer.BlockCopy(crcBytes, 0, message, offset, 4); + // Payload length + data + int strLen = WriteNullTerminatedString(buffer.Slice(offset), payload); + offset += strLen; - return message; + return offset; } - private static int InsertHeaderAndName(byte[] byteName, int messageLength, byte[] message) + private static int FinishPacket(Span<byte> buffer, int offset) { - // check to see if we need to flip endiannes - bool flipEndian = BitConverter.IsLittleEndian; - int offset = 0; - - // create header bytes - var getSetBytes = BitConverter.GetBytes(GetSetRequest); - var msgLenBytes = BitConverter.GetBytes((ushort)(messageLength - 8)); // Subtrace 4 bytes for header and 4 bytes for crc - - if (flipEndian) - { - Array.Reverse(getSetBytes); - Array.Reverse(msgLenBytes); - } + // Payload length + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(2), (ushort)(offset - 4)); - // insert header bytes into message - Buffer.BlockCopy(getSetBytes, 0, message, offset, 2); - offset += 2; - Buffer.BlockCopy(msgLenBytes, 0, message, offset, 2); - offset += 2; - - // insert tag name and length - message[offset++] = GetSetName; - message[offset++] = Convert.ToByte(byteName.Length); + // calculate crc and insert at the end of the message + var crc = Crc32.Compute(buffer.Slice(0, offset)); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.Slice(offset), crc); - // insert name string - Buffer.BlockCopy(byteName, 0, message, offset, byteName.Length); - offset += byteName.Length; + return offset + 4; + } - return offset; + internal static bool VerifyReturnValueOfGetSet(ReadOnlySpan<byte> buffer, string expected) + { + return TryGetReturnValueOfGetSet(buffer, out var value) + && string.Equals(Encoding.UTF8.GetString(value), expected, StringComparison.OrdinalIgnoreCase); } - private static bool ParseReturnMessage(byte[] buf, int numBytes, out string returnVal) + internal static bool TryGetReturnValueOfGetSet(ReadOnlySpan<byte> buffer, out ReadOnlySpan<byte> value) { - returnVal = string.Empty; + value = ReadOnlySpan<byte>.Empty; - if (numBytes < 4) + if (buffer.Length < 8) { return false; } - var flipEndian = BitConverter.IsLittleEndian; - int offset = 0; - byte[] msgTypeBytes = new byte[2]; - Buffer.BlockCopy(buf, offset, msgTypeBytes, 0, msgTypeBytes.Length); - - if (flipEndian) + uint crc = BinaryPrimitives.ReadUInt32LittleEndian(buffer[^4..]); + if (crc != Crc32.Compute(buffer[..^4])) { - Array.Reverse(msgTypeBytes); + return false; } - var msgType = BitConverter.ToUInt16(msgTypeBytes, 0); - offset += 2; - - if (msgType != GetSetReply) + if (BinaryPrimitives.ReadUInt16BigEndian(buffer) != GetSetReply) { return false; } - byte[] msgLengthBytes = new byte[2]; - Buffer.BlockCopy(buf, offset, msgLengthBytes, 0, msgLengthBytes.Length); - if (flipEndian) + var msgLength = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2)); + if (buffer.Length != 2 + 2 + 4 + msgLength) { - Array.Reverse(msgLengthBytes); + return false; } - var msgLength = BitConverter.ToUInt16(msgLengthBytes, 0); - offset += 2; - - if (numBytes < msgLength + 8) + var offset = 4; + if (buffer[offset++] != GetSetName) { return false; } - offset++; // Name Tag - - var nameLength = buf[offset++]; + var nameLength = buffer[offset++]; + if (buffer.Length < 4 + 1 + offset + nameLength) + { + return false; + } - // skip the name field to get to value for return offset += nameLength; - offset++; // Value Tag - - var valueLength = buf[offset++]; - - returnVal = Encoding.UTF8.GetString(buf, offset, valueLength - 1); // remove null terminator - return true; - } - - private static class HdHomerunCrc - { - private static uint[] crc_table = { - 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, - 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, - 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, - 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, - 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, - 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, - 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, - 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, - 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, - 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, - 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, - 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, - 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, - 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, - 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, - 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, - 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, - 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, - 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, - 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, - 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, - 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, - 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, - 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, - 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, - 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, - 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, - 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, - 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, - 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, - 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, - 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, - 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, - 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, - 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, - 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, - 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, - 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, - 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, - 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, - 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, - 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, - 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, - 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, - 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, - 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, - 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, - 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, - 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, - 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, - 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, - 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, - 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, - 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, - 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, - 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, - 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, - 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, - 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, - 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, - 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, - 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, - 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, - 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d }; - - public static uint GetCrc32(byte[] bytes, int numBytes) + if (buffer[offset++] != GetSetValue) { - var hash = 0xffffffff; - for (var i = 0; i < numBytes; i++) - { - hash = (hash >> 8) ^ crc_table[(hash ^ bytes[i]) & 0xff]; - } + return false; + } - var tmp = ~hash & 0xffffffff; - var b0 = tmp & 0xff; - var b1 = (tmp >> 8) & 0xff; - var b2 = (tmp >> 16) & 0xff; - var b3 = (tmp >> 24) & 0xff; - return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; + var valueLength = buffer[offset++]; + if (buffer.Length < 4 + offset + valueLength) + { + return false; } + + // remove null terminator + value = buffer.Slice(offset, valueLength - 1); + return true; } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs index 6730751d5..58e0c7448 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs @@ -1,14 +1,17 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; +using System.Net.NetworkInformation; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; @@ -26,7 +29,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun private readonly IServerApplicationHost _appHost; private readonly IHdHomerunChannelCommands _channelCommands; private readonly int _numTuners; - private readonly INetworkManager _networkManager; public HdHomerunUdpStream( MediaSourceInfo mediaSource, @@ -38,18 +40,36 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun ILogger logger, IConfigurationManager configurationManager, IServerApplicationHost appHost, - INetworkManager networkManager, IStreamHelper streamHelper) : base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper) { _appHost = appHost; - _networkManager = networkManager; OriginalStreamId = originalStreamId; _channelCommands = channelCommands; _numTuners = numTuners; EnableStreamSharing = true; } + /// <summary> + /// Returns an unused UDP port number in the range specified. + /// Temporarily placed here until future network PR merged. + /// </summary> + /// <param name="range">Upper and Lower boundary of ports to select.</param> + /// <returns>System.Int32.</returns> + private static int GetUdpPortFromRange((int Min, int Max) range) + { + var properties = IPGlobalProperties.GetIPGlobalProperties(); + + // Get active udp listeners. + var udpListenerPorts = properties.GetActiveUdpListeners() + .Where(n => n.Port >= range.Min && n.Port <= range.Max) + .Select(n => n.Port); + + return Enumerable + .Range(range.Min, range.Max) + .FirstOrDefault(i => !udpListenerPorts.Contains(i)); + } + public override async Task Open(CancellationToken openCancellationToken) { LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested(); @@ -57,7 +77,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var mediaSource = OriginalMediaSource; var uri = new Uri(mediaSource.Path); - var localPort = _networkManager.GetRandomUnusedUdpPort(); + // Temporary code to reduce PR size. This will be updated by a future network pr. + var localPort = GetUdpPortFromRange((49152, 65535)); Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath)); @@ -69,8 +90,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { try { - await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false); - localAddress = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address; + await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort, openCancellationToken).ConfigureAwait(false); + localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address; tcpClient.Close(); } catch (Exception ex) @@ -80,6 +101,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } + if (localAddress.IsIPv4MappedToIPv6) { + localAddress = localAddress.MapToIPv4(); + } + var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork); var hdHomerunManager = new HdHomerunManager(); @@ -99,7 +124,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun using (udpClient) using (hdHomerunManager) { - if (!(ex is OperationCanceledException)) + if (ex is not OperationCanceledException) { Logger.LogError(ex, "Error opening live stream:"); } @@ -110,12 +135,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var taskCompletionSource = new TaskCompletionSource<bool>(); - await StartStreaming( + _ = StartStreaming( udpClient, hdHomerunManager, remoteAddress, taskCompletionSource, - LiveStreamCancellationTokenSource.Token).ConfigureAwait(false); + LiveStreamCancellationTokenSource.Token); // OpenedMediaSource.Protocol = MediaProtocol.File; // OpenedMediaSource.Path = tempFile; @@ -131,33 +156,35 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun await taskCompletionSource.Task.ConfigureAwait(false); } - private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + public string GetFilePath() { - return Task.Run(async () => + return TempFilePath; + } + + private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + { + using (udpClient) + using (hdHomerunManager) { - using (udpClient) - using (hdHomerunManager) + try { - try - { - await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException ex) - { - Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress); - openTaskCompletionSource.TrySetException(ex); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error opening live stream:"); - openTaskCompletionSource.TrySetException(ex); - } - - EnableStreamSharing = false; + await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException ex) + { + Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress); + openTaskCompletionSource.TrySetException(ex); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error opening live stream:"); + openTaskCompletionSource.TrySetException(ex); + } + + EnableStreamSharing = false; + } - await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); - }); + await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); } private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs index 0333e723b..96a678c1d 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -150,7 +152,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public async Task CopyToAsync(Stream stream, CancellationToken cancellationToken) { - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, LiveStreamCancellationTokenSource.Token).Token; + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, LiveStreamCancellationTokenSource.Token); + cancellationToken = linkedCancellationTokenSource.Token; // use non-async filestream on windows along with read due to https://github.com/dotnet/corefx/issues/6039 var allowAsync = Environment.OSVersion.Platform != PlatformID.Win32NT; @@ -182,7 +185,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (string.IsNullOrEmpty(currentFile)) { - return (files.Last(), true); + return (files[^1], true); } var nextIndex = files.FindIndex(i => string.Equals(i, currentFile, StringComparison.OrdinalIgnoreCase)) + 1; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index 8fc29fb4a..8fa6f5ad6 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -5,6 +7,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Extensions; @@ -26,7 +29,15 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost { - private readonly IHttpClient _httpClient; + private static readonly string[] _disallowedSharedStreamExtensions = + { + ".mkv", + ".mp4", + ".m3u8", + ".mpd" + }; + + private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; private readonly INetworkManager _networkManager; private readonly IMediaSourceManager _mediaSourceManager; @@ -37,14 +48,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts IMediaSourceManager mediaSourceManager, ILogger<M3UTunerHost> logger, IFileSystem fileSystem, - IHttpClient httpClient, + IHttpClientFactory httpClientFactory, IServerApplicationHost appHost, INetworkManager networkManager, IStreamHelper streamHelper, IMemoryCache memoryCache) : base(config, logger, fileSystem, memoryCache) { - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _appHost = appHost; _networkManager = networkManager; _mediaSourceManager = mediaSourceManager; @@ -64,7 +75,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var channelIdPrefix = GetFullChannelIdPrefix(info); - return await new M3uParser(Logger, _httpClient, _appHost).Parse(info.Url, channelIdPrefix, info.Id, cancellationToken).ConfigureAwait(false); + return await new M3uParser(Logger, _httpClientFactory) + .Parse(info, channelIdPrefix, cancellationToken) + .ConfigureAwait(false); } public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) @@ -83,14 +96,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return Task.FromResult(list); } - private static readonly string[] _disallowedSharedStreamExtensions = - { - ".mkv", - ".mp4", - ".m3u8", - ".mpd" - }; - protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo info, ChannelInfo channelInfo, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { var tunerCount = info.TunerCount; @@ -116,7 +121,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (!_disallowedSharedStreamExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) { - return new SharedHttpStream(mediaSource, info, streamId, FileSystem, _httpClient, Logger, Config, _appHost, _streamHelper); + return new SharedHttpStream(mediaSource, info, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); } } @@ -125,7 +130,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts public async Task Validate(TunerHostInfo info) { - using (var stream = await new M3uParser(Logger, _httpClient, _appHost).GetListingsStream(info.Url, CancellationToken.None).ConfigureAwait(false)) + using (var stream = await new M3uParser(Logger, _httpClientFactory).GetListingsStream(info, CancellationToken.None).ConfigureAwait(false)) { } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index 875977219..16ff98a7d 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -1,108 +1,110 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; +using System.Net.Http; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Controller; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.LiveTv; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts { public class M3uParser { + private const string ExtInfPrefix = "#EXTINF:"; + private readonly ILogger _logger; - private readonly IHttpClient _httpClient; - private readonly IServerApplicationHost _appHost; + private readonly IHttpClientFactory _httpClientFactory; - public M3uParser(ILogger logger, IHttpClient httpClient, IServerApplicationHost appHost) + public M3uParser(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; - _httpClient = httpClient; - _appHost = appHost; + _httpClientFactory = httpClientFactory; } - public async Task<List<ChannelInfo>> Parse(string url, string channelIdPrefix, string tunerHostId, CancellationToken cancellationToken) + public async Task<List<ChannelInfo>> Parse(TunerHostInfo info, string channelIdPrefix, CancellationToken cancellationToken) { // Read the file and display it line by line. - using (var reader = new StreamReader(await GetListingsStream(url, cancellationToken).ConfigureAwait(false))) + using (var reader = new StreamReader(await GetListingsStream(info, cancellationToken).ConfigureAwait(false))) { - return GetChannels(reader, channelIdPrefix, tunerHostId); + return await GetChannelsAsync(reader, channelIdPrefix, info.Id).ConfigureAwait(false); } } - public List<ChannelInfo> ParseString(string text, string channelIdPrefix, string tunerHostId) + public async Task<Stream> GetListingsStream(TunerHostInfo info, CancellationToken cancellationToken) { - // Read the file and display it line by line. - using (var reader = new StringReader(text)) + if (info == null) { - return GetChannels(reader, channelIdPrefix, tunerHostId); + throw new ArgumentNullException(nameof(info)); } - } - public Task<Stream> GetListingsStream(string url, CancellationToken cancellationToken) - { - if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (!info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - return _httpClient.Get(new HttpRequestOptions - { - Url = url, - CancellationToken = cancellationToken, - // Some data providers will require a user agent - UserAgent = _appHost.ApplicationUserAgent - }); + return File.OpenRead(info.Url); } - return Task.FromResult((Stream)File.OpenRead(url)); - } + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url); + if (!string.IsNullOrEmpty(info.UserAgent)) + { + requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent); + } - private const string ExtInfPrefix = "#EXTINF:"; + // Set HttpCompletionOption.ResponseHeadersRead to prevent timeouts on larger files + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(cancellationToken); + } - private List<ChannelInfo> GetChannels(TextReader reader, string channelIdPrefix, string tunerHostId) + private async Task<List<ChannelInfo>> GetChannelsAsync(TextReader reader, string channelIdPrefix, string tunerHostId) { var channels = new List<ChannelInfo>(); - string line; string extInf = string.Empty; - while ((line = reader.ReadLine()) != null) + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - line = line.Trim(); - if (string.IsNullOrWhiteSpace(line)) + var trimmedLine = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmedLine)) { continue; } - if (line.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase)) + if (trimmedLine.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase)) { continue; } - if (line.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase)) + if (trimmedLine.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase)) { - extInf = line.Substring(ExtInfPrefix.Length).Trim(); - _logger.LogInformation("Found m3u channel: {0}", extInf); + extInf = trimmedLine.Substring(ExtInfPrefix.Length).Trim(); } - else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith("#", StringComparison.OrdinalIgnoreCase)) + else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#')) { - var channel = GetChannelnfo(extInf, tunerHostId, line); + var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine); if (string.IsNullOrWhiteSpace(channel.Id)) { - channel.Id = channelIdPrefix + line.GetMD5().ToString("N", CultureInfo.InvariantCulture); + channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); } else { channel.Id = channelIdPrefix + channel.Id.GetMD5().ToString("N", CultureInfo.InvariantCulture); } - channel.Path = line; + channel.Path = trimmedLine; channels.Add(channel); + _logger.LogInformation("Parsed channel: {ChannelName}", channel.Name); extInf = string.Empty; } } @@ -127,6 +129,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts channel.ImageUrl = value; } + if (attributes.TryGetValue("group-title", out string groupTitle)) + { + channel.ChannelGroup = groupTitle; + } + channel.Name = GetChannelName(extInf, attributes); channel.Number = GetChannelNumber(extInf, attributes, mediaUrl); @@ -149,7 +156,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (channelIdValues.Count > 0) { - channel.Id = string.Join("_", channelIdValues); + channel.Id = string.Join('_', channelIdValues); } return channel; @@ -157,7 +164,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl) { - var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries); var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty; string numberString = null; @@ -191,7 +198,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (string.IsNullOrWhiteSpace(numberString)) { // Using this as a fallback now as this leads to Problems with channels like "5 USA" - // where 5 isnt ment to be the channel number + // where 5 isn't ment to be the channel number // Check for channel number with the format from SatIp // #EXTINF:0,84. VOX Schweiz // #EXTINF:0,84.0 - VOX Schweiz @@ -267,8 +274,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private static string GetChannelName(string extInf, Dictionary<string, string> attributes) { - var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - var nameInExtInf = nameParts.Length > 1 ? nameParts.Last().Trim() : null; + var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries); + var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null; // Check for channel number with the format from SatIp // #EXTINF:0,84. VOX Schweiz diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index bc4dcd894..f572151b8 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -21,7 +23,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { public class SharedHttpStream : LiveStream, IDirectStreamProvider { - private readonly IHttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; public SharedHttpStream( @@ -29,14 +31,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts TunerHostInfo tunerHostInfo, string originalStreamId, IFileSystem fileSystem, - IHttpClient httpClient, + IHttpClientFactory httpClientFactory, ILogger logger, IConfigurationManager configurationManager, IServerApplicationHost appHost, IStreamHelper streamHelper) : base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper) { - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _appHost = appHost; OriginalStreamId = originalStreamId; EnableStreamSharing = true; @@ -55,25 +57,15 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var typeName = GetType().Name; Logger.LogInformation("Opening " + typeName + " Live stream from {0}", url); - var httpRequestOptions = new HttpRequestOptions - { - Url = url, - CancellationToken = CancellationToken.None, - BufferContent = false, - DecompressionMethod = CompressionMethods.None - }; - - foreach (var header in mediaSource.RequiredHttpHeaders) - { - httpRequestOptions.RequestHeaders[header.Key] = header.Value; - } - - var response = await _httpClient.SendAsync(httpRequestOptions, HttpMethod.Get).ConfigureAwait(false); + // Response stream is disposed manually. + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None) + .ConfigureAwait(false); var extension = "ts"; var requiresRemux = false; - var contentType = response.ContentType ?? string.Empty; + var contentType = response.Content.Headers.ContentType?.ToString() ?? string.Empty; if (contentType.IndexOf("matroska", StringComparison.OrdinalIgnoreCase) != -1) { requiresRemux = true; @@ -99,8 +91,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var taskCompletionSource = new TaskCompletionSource<bool>(); - var now = DateTime.UtcNow; - _ = StartStreaming(response, taskCompletionSource, LiveStreamCancellationTokenSource.Token); // OpenedMediaSource.Protocol = MediaProtocol.File; @@ -128,28 +118,31 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (!taskCompletionSource.Task.Result) { Logger.LogWarning("Zero bytes copied from stream {0} to {1} but no exception raised", GetType().Name, TempFilePath); - throw new EndOfStreamException(String.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name)); + throw new EndOfStreamException(string.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name)); } } - private Task StartStreaming(HttpResponseInfo response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + public string GetFilePath() + { + return TempFilePath; + } + + private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) { return Task.Run(async () => { try { Logger.LogInformation("Beginning {0} stream to {1}", GetType().Name, TempFilePath); - using (response) - using (var stream = response.Content) - using (var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read)) - { - await StreamHelper.CopyToAsync( - stream, - fileStream, - IODefaults.CopyToBufferSize, - () => Resolve(openTaskCompletionSource), - cancellationToken).ConfigureAwait(false); - } + using var message = response; + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); + await StreamHelper.CopyToAsync( + stream, + fileStream, + IODefaults.CopyToBufferSize, + () => Resolve(openTaskCompletionSource), + cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException ex) { @@ -166,7 +159,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts EnableStreamSharing = false; await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); - }); + }, CancellationToken.None); } private void Resolve(TaskCompletionSource<bool> openTaskCompletionSource) diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json index e587c37d5..18f17dda9 100644 --- a/Emby.Server.Implementations/Localization/Core/af.json +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -1,29 +1,29 @@ { "Artists": "Kunstenare", "Channels": "Kanale", - "Folders": "Fouers", + "Folders": "Lêergidse", "Favorites": "Gunstelinge", "HeaderFavoriteShows": "Gunsteling Vertonings", "ValueSpecialEpisodeName": "Spesiale - {0}", - "HeaderAlbumArtists": "Album Kunstenaars", + "HeaderAlbumArtists": "Kunstenaars se Album", "Books": "Boeke", "HeaderNextUp": "Volgende", - "Movies": "Rolprente", - "Shows": "Program", - "HeaderContinueWatching": "Hou Aan Kyk", + "Movies": "Flieks", + "Shows": "Televisie Reekse", + "HeaderContinueWatching": "Kyk Verder", "HeaderFavoriteEpisodes": "Gunsteling Episodes", - "Photos": "Fotos", - "Playlists": "Speellysse", + "Photos": "Foto's", + "Playlists": "Snitlyste", "HeaderFavoriteArtists": "Gunsteling Kunstenaars", "HeaderFavoriteAlbums": "Gunsteling Albums", "Sync": "Sinkroniseer", "HeaderFavoriteSongs": "Gunsteling Liedjies", "Songs": "Liedjies", - "DeviceOnlineWithName": "{0} gekoppel is", + "DeviceOnlineWithName": "{0} is gekoppel", "DeviceOfflineWithName": "{0} is ontkoppel", "Collections": "Versamelings", "Inherit": "Ontvang", - "HeaderLiveTV": "Live TV", + "HeaderLiveTV": "Lewendige TV", "Application": "Program", "AppDeviceValues": "App: {0}, Toestel: {1}", "VersionNumber": "Weergawe {0}", @@ -71,7 +71,7 @@ "NameSeasonUnknown": "Seisoen Onbekend", "NameSeasonNumber": "Seisoen {0}", "NameInstallFailed": "{0} installering het misluk", - "MusicVideos": "Musiek videos", + "MusicVideos": "Musiek Videos", "Music": "Musiek", "MixedContent": "Gemengde inhoud", "MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer", @@ -79,21 +79,45 @@ "MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}", "MessageApplicationUpdated": "Jellyfin Bediener is opgedateer", "Latest": "Nuutste", - "LabelRunningTimeValue": "Lopende tyd: {0}", + "LabelRunningTimeValue": "Werktyd: {0}", "LabelIpAddressValue": "IP adres: {0}", "ItemRemovedWithName": "{0} is uit versameling verwyder", - "ItemAddedWithName": "{0} is in die versameling", - "HomeVideos": "Tuis opnames", + "ItemAddedWithName": "{0} is by die versameling gevoeg", + "HomeVideos": "Tuis Videos", "HeaderRecordingGroups": "Groep Opnames", - "HeaderCameraUploads": "Kamera Oplaai", "Genres": "Genres", "FailedLoginAttemptWithUserName": "Mislukte aansluiting van {0}", - "ChapterNameValue": "Hoofstuk", + "ChapterNameValue": "Hoofstuk {0}", "CameraImageUploadedFrom": "'n Nuwe kamera photo opgelaai van {0}", "AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer", "Albums": "Albums", "TasksChannelsCategory": "Internet kanale", "TasksApplicationCategory": "aansoek", "TasksLibraryCategory": "biblioteek", - "TasksMaintenanceCategory": "onderhoud" + "TasksMaintenanceCategory": "onderhoud", + "TaskCleanCacheDescription": "Vee kasregister lêers uit wat nie meer deur die stelsel benodig word nie.", + "TaskCleanCache": "Reinig Kasgeheue Lêergids", + "TaskDownloadMissingSubtitlesDescription": "Soek aanlyn vir vermiste onderskrifte gebasseer op metadata verstellings.", + "TaskDownloadMissingSubtitles": "Laai vermiste onderskrifte af", + "TaskRefreshChannelsDescription": "Vervris internet kanaal inligting.", + "TaskRefreshChannels": "Vervris Kanale", + "TaskCleanTranscodeDescription": "Vee transkodering lêers uit wat ouer is as een dag.", + "TaskCleanTranscode": "Reinig Transkoderings Leêrbinder", + "TaskUpdatePluginsDescription": "Laai opgedateerde inprop-sagteware af en installeer inprop-sagteware wat verstel is om outomaties op te dateer.", + "TaskUpdatePlugins": "Dateer Inprop-Sagteware Op", + "TaskRefreshPeopleDescription": "Vervris metadata oor akteurs en regisseurs in u media versameling.", + "TaskRefreshPeople": "Vervris Mense", + "TaskCleanLogsDescription": "Vee loglêers wat ouer as {0} dae is uit.", + "TaskCleanLogs": "Reinig Loglêer Lêervouer", + "TaskRefreshLibraryDescription": "Skandeer u media versameling vir nuwe lêers en verfris metadata.", + "TaskRefreshLibrary": "Skandeer Media Versameling", + "TaskRefreshChapterImagesDescription": "Maak kleinkiekeis (fotos) vir films wat hoofstukke het.", + "TaskRefreshChapterImages": "Verkry Hoofstuk Beelde", + "Undefined": "Ongedefineerd", + "Forced": "Geforseer", + "Default": "Oorspronklik", + "TaskCleanActivityLogDescription": "Verwyder aktiwiteitsaantekeninge ouer as die opgestelde ouderdom.", + "TaskCleanActivityLog": "Maak Aktiwiteitsaantekeninge Skoon", + "TaskOptimizeDatabaseDescription": "Komprimeer databasis en verkort vrye ruimte. As hierdie taak uitgevoer word nadat die media versameling geskandeer is of ander veranderings aangebring is wat databasisaanpassings impliseer, kan dit die prestasie verbeter.", + "TaskOptimizeDatabase": "Optimaliseer databasis" } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 4eac8e75d..be629c8a4 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -16,7 +16,6 @@ "Folders": "المجلدات", "Genres": "التضنيفات", "HeaderAlbumArtists": "فناني الألبومات", - "HeaderCameraUploads": "تحميلات الكاميرا", "HeaderContinueWatching": "استئناف", "HeaderFavoriteAlbums": "الألبومات المفضلة", "HeaderFavoriteArtists": "الفنانون المفضلون", @@ -114,5 +113,12 @@ "TaskRefreshPeopleDescription": "تحديث البيانات الوصفية للممثلين والمخرجين في مكتبة الوسائط الخاصة بك.", "TaskRefreshPeople": "إعادة تحميل الأشخاص", "TaskCleanLogsDescription": "حذف السجلات الأقدم من {0} يوم.", - "TaskCleanLogs": "حذف دليل السجل" + "TaskCleanLogs": "حذف دليل السجل", + "TaskCleanActivityLogDescription": "يحذف سجل الأنشطة الأقدم من الوقت الموضوع.", + "TaskCleanActivityLog": "حذف سجل الأنشطة", + "Default": "الإعدادات الافتراضية", + "Undefined": "غير معرف", + "Forced": "ملحقة", + "TaskOptimizeDatabaseDescription": "يضغط قاعدة البيانات ويقتطع المساحة الحرة. تشغيل هذه المهمة بعد فحص المكتبة أو إجراء تغييرات أخرى تشير ضمنًا إلى أن تعديلات قاعدة البيانات قد تؤدي إلى تحسين الأداء.", + "TaskOptimizeDatabase": "تحسين قاعدة البيانات" } diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 3fc7c7dc0..f55287d15 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -16,7 +16,6 @@ "Folders": "Папки", "Genres": "Жанрове", "HeaderAlbumArtists": "Изпълнители на албуми", - "HeaderCameraUploads": "Качени от камера", "HeaderContinueWatching": "Продължаване на гледането", "HeaderFavoriteAlbums": "Любими албуми", "HeaderFavoriteArtists": "Любими изпълнители", @@ -30,17 +29,17 @@ "Inherit": "Наследяване", "ItemAddedWithName": "{0} е добавено към библиотеката", "ItemRemovedWithName": "{0} е премахнато от библиотеката", - "LabelIpAddressValue": "ИП адрес: {0}", - "LabelRunningTimeValue": "Стартирано от: {0}", + "LabelIpAddressValue": "IP адрес: {0}", + "LabelRunningTimeValue": "Продължителност: {0}", "Latest": "Последни", - "MessageApplicationUpdated": "Сървърът е обновен", - "MessageApplicationUpdatedTo": "Сървърът е обновен до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация се актуализира", - "MessageServerConfigurationUpdated": "Конфигурацията на сървъра се актуализира", + "MessageApplicationUpdated": "Сървърът беше обновен", + "MessageApplicationUpdatedTo": "Сървърът беше обновен до {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация беше актуализирана", + "MessageServerConfigurationUpdated": "Конфигурацията на сървъра беше актуализирана", "MixedContent": "Смесено съдържание", "Movies": "Филми", "Music": "Музика", - "MusicVideos": "Музикални клипове", + "MusicVideos": "Музикални видеа", "NameInstallFailed": "{0} не можа да се инсталира", "NameSeasonNumber": "Сезон {0}", "NameSeasonUnknown": "Неразпознат сезон", @@ -56,26 +55,26 @@ "NotificationOptionPluginInstalled": "Приставката е инсталирана", "NotificationOptionPluginUninstalled": "Приставката е деинсталирана", "NotificationOptionPluginUpdateInstalled": "Обновлението на приставката е инсталирано", - "NotificationOptionServerRestartRequired": "Нужно е повторно пускане на сървъра", + "NotificationOptionServerRestartRequired": "Сървърът трябва да се рестартира", "NotificationOptionTaskFailed": "Грешка в планирана задача", - "NotificationOptionUserLockedOut": "Потребителя е заключен", + "NotificationOptionUserLockedOut": "Потребителят е заключен", "NotificationOptionVideoPlayback": "Възпроизвеждането на видео започна", "NotificationOptionVideoPlaybackStopped": "Възпроизвеждането на видео е спряно", "Photos": "Снимки", "Playlists": "Списъци", - "Plugin": "Приставка", - "PluginInstalledWithName": "{0} е инсталирано", - "PluginUninstalledWithName": "{0} е деинсталирано", - "PluginUpdatedWithName": "{0} е обновено", + "Plugin": "Добавка", + "PluginInstalledWithName": "{0} е инсталиранa", + "PluginUninstalledWithName": "{0} е деинсталиранa", + "PluginUpdatedWithName": "{0} е обновенa", "ProviderValue": "Доставчик: {0}", "ScheduledTaskFailedWithName": "{0} се провали", "ScheduledTaskStartedWithName": "{0} започна", - "ServerNameNeedsToBeRestarted": "{0} е нужно да се рестартира", + "ServerNameNeedsToBeRestarted": "{0} трябва да се рестартира", "Shows": "Сериали", "Songs": "Песни", "StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.", "SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}", - "SubtitleDownloadFailureFromForItem": "Поднадписите за {1} от {0} не можаха да се изтеглят", + "SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени", "Sync": "Синхронизиране", "System": "Система", "TvShows": "Телевизионни сериали", @@ -93,12 +92,12 @@ "ValueHasBeenAddedToLibrary": "{0} беше добавен във Вашата библиотека", "ValueSpecialEpisodeName": "Специални - {0}", "VersionNumber": "Версия {0}", - "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи поднадписи, на база конфигурацията за мета-данни.", - "TaskDownloadMissingSubtitles": "Изтегляне на липсващи поднадписи", + "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи субтитри, на база конфигурацията за мета-данни.", + "TaskDownloadMissingSubtitles": "Изтегляне на липсващи субтитри", "TaskRefreshChannelsDescription": "Обновява информацията за интернет канала.", "TaskRefreshChannels": "Обновяване на Канали", - "TaskCleanTranscodeDescription": "Изтрива прекодирани файлове по-стари от един ден.", - "TaskCleanTranscode": "Изчиства директорията за прекодиране", + "TaskCleanTranscodeDescription": "Изтрива транскодирани файлове по-стари от един ден.", + "TaskCleanTranscode": "Изчиства директорията за транскодиране", "TaskUpdatePluginsDescription": "Изтегля и инсталира актуализации за добавките, които са настроени за автоматична актуализация.", "TaskUpdatePlugins": "Актуализира добавките", "TaskRefreshPeopleDescription": "Актуализира мета-данните за артистите и режисьорите за Вашата медийна библиотека.", @@ -114,5 +113,12 @@ "TasksChannelsCategory": "Интернет Канали", "TasksApplicationCategory": "Приложение", "TasksLibraryCategory": "Библиотека", - "TasksMaintenanceCategory": "Поддръжка" + "TasksMaintenanceCategory": "Поддръжка", + "Undefined": "Неопределено", + "Forced": "Принудително", + "Default": "По подразбиране", + "TaskCleanActivityLogDescription": "Изтрива записите в дневника с активност по стари от конфигурираната възраст.", + "TaskCleanActivityLog": "Изчисти дневника с активност", + "TaskOptimizeDatabaseDescription": "Прави базата данни по-компактна и освобождава място. Пускането на тази задача след сканиране на библиотеката или правене на други промени, свързани с модификации на базата данни, може да подобри производителността.", + "TaskOptimizeDatabase": "Оптимизирай базата данни" } diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json index 1bd190982..c3fbe2408 100644 --- a/Emby.Server.Implementations/Localization/Core/bn.json +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -1,7 +1,7 @@ { "DeviceOnlineWithName": "{0}-এর সাথে সংযুক্ত হয়েছে", "DeviceOfflineWithName": "{0}-এর সাথে সংযোগ বিচ্ছিন্ন হয়েছে", - "Collections": "কলেক্শন", + "Collections": "সংগ্রহ", "ChapterNameValue": "অধ্যায় {0}", "Channels": "চ্যানেল", "CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে", @@ -14,9 +14,8 @@ "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", "HeaderContinueWatching": "দেখতে থাকুন", - "HeaderCameraUploads": "ক্যামেরার আপলোড সমূহ", - "HeaderAlbumArtists": "এলবাম শিল্পী", - "Genres": "জেনার", + "HeaderAlbumArtists": "এলবাম শিল্পীবৃন্দ", + "Genres": "শৈলী", "Folders": "ফোল্ডারগুলো", "Favorites": "পছন্দসমূহ", "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", @@ -113,5 +112,10 @@ "TaskRefreshPeople": "পিপল রিফ্রেশ করুন", "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলুন।", "TaskCleanLogs": "লগ ডিরেক্টরি ক্লিন করুন", - "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।" + "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।", + "Undefined": "অসঙ্গায়িত", + "Forced": "জোরকরে", + "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের কাজের হিসাব মুছে দিন খালি করুন.", + "TaskCleanActivityLog": "কাজের ফাইল খালি করুন", + "Default": "প্রাথমিক" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 2c802a39e..7715daa7c 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -5,7 +5,7 @@ "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "Books": "Llibres", - "CameraImageUploadedFrom": "Una nova imatge de la càmera ha estat pujada des de {0}", + "CameraImageUploadedFrom": "S'ha pujat una nova imatge des de la camera desde {0}", "Channels": "Canals", "ChapterNameValue": "Capítol {0}", "Collections": "Col·leccions", @@ -16,13 +16,12 @@ "Folders": "Carpetes", "Genres": "Gèneres", "HeaderAlbumArtists": "Artistes del Àlbum", - "HeaderCameraUploads": "Pujades de Càmera", "HeaderContinueWatching": "Continua Veient", "HeaderFavoriteAlbums": "Àlbums Preferits", - "HeaderFavoriteArtists": "Artistes Preferits", - "HeaderFavoriteEpisodes": "Episodis Preferits", - "HeaderFavoriteShows": "Programes Preferits", - "HeaderFavoriteSongs": "Cançons Preferides", + "HeaderFavoriteArtists": "Artistes Predilectes", + "HeaderFavoriteEpisodes": "Episodis Predilectes", + "HeaderFavoriteShows": "Programes Predilectes", + "HeaderFavoriteSongs": "Cançons Predilectes", "HeaderLiveTV": "TV en Directe", "HeaderNextUp": "A continuació", "HeaderRecordingGroups": "Grups d'Enregistrament", @@ -37,7 +36,7 @@ "MessageApplicationUpdatedTo": "El Servidor de Jellyfin ha estat actualitzat a {0}", "MessageNamedServerConfigurationUpdatedWithValue": "La secció {0} de la configuració del servidor ha estat actualitzada", "MessageServerConfigurationUpdated": "S'ha actualitzat la configuració del servidor", - "MixedContent": "Contingut mesclat", + "MixedContent": "Contingut barrejat", "Movies": "Pel·lícules", "Music": "Música", "MusicVideos": "Vídeos musicals", @@ -77,7 +76,7 @@ "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Els subtítols no s'han pogut baixar de {0} per {1}", "Sync": "Sincronitzar", - "System": "System", + "System": "Sistema", "TvShows": "Espectacles de TV", "User": "User", "UserCreatedWithName": "S'ha creat l'usuari {0}", @@ -114,5 +113,12 @@ "TasksChannelsCategory": "Canals d'internet", "TasksApplicationCategory": "Aplicació", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Manteniment" + "TasksMaintenanceCategory": "Manteniment", + "TaskCleanActivityLogDescription": "Eliminat entrades del registre d'activitats mes antigues que l'antiguitat configurada.", + "TaskCleanActivityLog": "Buidar Registre d'Activitat", + "Undefined": "Indefinit", + "Forced": "Forçat", + "Default": "Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la biblioteca o fer altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.", + "TaskOptimizeDatabase": "Optimitzar la base de dades" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 464ca28ca..62b2b6328 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -16,7 +16,6 @@ "Folders": "Složky", "Genres": "Žánry", "HeaderAlbumArtists": "Umělci alba", - "HeaderCameraUploads": "Nahrané fotografie", "HeaderContinueWatching": "Pokračovat ve sledování", "HeaderFavoriteAlbums": "Oblíbená alba", "HeaderFavoriteArtists": "Oblíbení interpreti", @@ -40,7 +39,7 @@ "MixedContent": "Smíšený obsah", "Movies": "Filmy", "Music": "Hudba", - "MusicVideos": "Hudební klipy", + "MusicVideos": "Hudební videa", "NameInstallFailed": "Instalace {0} selhala", "NameSeasonNumber": "Sezóna {0}", "NameSeasonUnknown": "Neznámá sezóna", @@ -114,5 +113,12 @@ "TasksChannelsCategory": "Internetové kanály", "TasksApplicationCategory": "Aplikace", "TasksLibraryCategory": "Knihovna", - "TasksMaintenanceCategory": "Údržba" + "TasksMaintenanceCategory": "Údržba", + "TaskCleanActivityLogDescription": "Smazat záznamy o aktivitě, které jsou starší než zadaná doba.", + "TaskCleanActivityLog": "Smazat záznam aktivity", + "Undefined": "Nedefinované", + "Forced": "Vynucené", + "Default": "Výchozí", + "TaskOptimizeDatabaseDescription": "Zmenší databázi a odstraní prázdné místo. Spuštění této úlohy po skenování knihovny či jiných změnách databáze může zlepšit výkon.", + "TaskOptimizeDatabase": "Optimalizovat databázi" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index f5397b62c..b2c484a31 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -1,5 +1,5 @@ { - "Albums": "Albums", + "Albums": "Albummer", "AppDeviceValues": "App: {0}, Enhed: {1}", "Application": "Applikation", "Artists": "Kunstnere", @@ -16,7 +16,6 @@ "Folders": "Mapper", "Genres": "Genrer", "HeaderAlbumArtists": "Albumkunstnere", - "HeaderCameraUploads": "Kamera Uploads", "HeaderContinueWatching": "Fortsæt Afspilning", "HeaderFavoriteAlbums": "Favoritalbummer", "HeaderFavoriteArtists": "Favoritkunstnere", @@ -40,7 +39,7 @@ "MixedContent": "Blandet indhold", "Movies": "Film", "Music": "Musik", - "MusicVideos": "Musikvideoer", + "MusicVideos": "Musik videoer", "NameInstallFailed": "{0} installationen mislykkedes", "NameSeasonNumber": "Sæson {0}", "NameSeasonUnknown": "Ukendt Sæson", @@ -114,5 +113,12 @@ "TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end en dag gammel.", "TaskCleanTranscode": "Rengør Transcode Mappen", "TaskRefreshPeople": "Genopfrisk Personer", - "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek." + "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek.", + "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigureret alder.", + "TaskCleanActivityLog": "Ryd Aktivitetslog", + "Undefined": "Udefineret", + "Forced": "Tvunget", + "Default": "Standard", + "TaskOptimizeDatabaseDescription": "Kompakter database og forkorter fri plads. Ved at køre denne proces efter at scanne biblioteket eller efter at ændre noget som kunne have indflydelse på databasen, kan forbedre ydeevne.", + "TaskOptimizeDatabase": "Optimér database" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index fcbe9566e..c924e5c15 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -3,7 +3,7 @@ "AppDeviceValues": "App: {0}, Gerät: {1}", "Application": "Anwendung", "Artists": "Interpreten", - "AuthenticationSucceededWithUserName": "{0} hat sich erfolgreich angemeldet", + "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", "CameraImageUploadedFrom": "Ein neues Kamerafoto wurde von {0} hochgeladen", "Channels": "Kanäle", @@ -16,8 +16,7 @@ "Folders": "Verzeichnisse", "Genres": "Genres", "HeaderAlbumArtists": "Album-Interpreten", - "HeaderCameraUploads": "Kamera-Uploads", - "HeaderContinueWatching": "Fortsetzen", + "HeaderContinueWatching": "Weiterschauen", "HeaderFavoriteAlbums": "Lieblingsalben", "HeaderFavoriteArtists": "Lieblings-Interpreten", "HeaderFavoriteEpisodes": "Lieblingsepisoden", @@ -95,24 +94,31 @@ "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Durchsucht das Internet nach fehlenden Untertiteln, basierend auf den Meta Einstellungen.", "TaskDownloadMissingSubtitles": "Lade fehlende Untertitel herunter", - "TaskRefreshChannelsDescription": "Erneuere Internet Kanal Informationen.", - "TaskRefreshChannels": "Erneuere Kanäle", - "TaskCleanTranscodeDescription": "Löscht Transkodierdateien welche älter als ein Tag sind.", - "TaskCleanTranscode": "Lösche Transkodier Pfad", - "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche dazu eingestellt sind automatisch zu updaten und installiert sie.", - "TaskUpdatePlugins": "Update Plugins", - "TaskRefreshPeopleDescription": "Erneuert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", - "TaskRefreshPeople": "Erneuere Schauspieler", - "TaskCleanLogsDescription": "Lösche Log Dateien die älter als {0} Tage sind.", - "TaskCleanLogs": "Lösche Log Pfad", - "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.", + "TaskRefreshChannelsDescription": "Aktualisiere Internet Kanal Informationen.", + "TaskRefreshChannels": "Aktualisiere Kanäle", + "TaskCleanTranscodeDescription": "Löscht Transkodierdateien, welche älter als einen Tag sind.", + "TaskCleanTranscode": "Lösche Transkodier-Pfad", + "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.", + "TaskUpdatePlugins": "Aktualisiere Plugins", + "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", + "TaskRefreshPeople": "Aktualisiere Schauspieler", + "TaskCleanLogsDescription": "Lösche Log Dateien, die älter als {0} Tage sind.", + "TaskCleanLogs": "Lösche Log-Verzeichnis", + "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiere Metadaten.", "TaskRefreshLibrary": "Scanne Medien-Bibliothek", - "TaskRefreshChapterImagesDescription": "Kreiert Vorschaubilder für Videos welche Kapitel haben.", + "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, welche Kapitel besitzen.", "TaskRefreshChapterImages": "Extrahiert Kapitel-Bilder", - "TaskCleanCacheDescription": "Löscht Zwischenspeicherdatein die nicht länger von System gebraucht werden.", - "TaskCleanCache": "Leere Cache Pfad", + "TaskCleanCacheDescription": "Löscht nicht mehr benötigte Zwischenspeicherdateien.", + "TaskCleanCache": "Leere Zwischenspeicher", "TasksChannelsCategory": "Internet Kanäle", "TasksApplicationCategory": "Anwendung", "TasksLibraryCategory": "Bibliothek", - "TasksMaintenanceCategory": "Wartung" + "TasksMaintenanceCategory": "Wartung", + "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.", + "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", + "Undefined": "Undefiniert", + "Forced": "Erzwungen", + "Default": "Standard", + "TaskOptimizeDatabaseDescription": "Komprimiert die Datenbank und trimmt den freien Speicherplatz. Die Ausführung dieser Aufgabe nach dem Scannen der Bibliothek oder nach anderen Änderungen, die Datenbankänderungen implizieren, kann die Leistung verbessern.", + "TaskOptimizeDatabase": "Datenbank optimieren" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 0753ea39d..697063f26 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -1,5 +1,5 @@ { - "Albums": "Άλμπουμς", + "Albums": "Άλμπουμ", "AppDeviceValues": "Εφαρμογή: {0}, Συσκευή: {1}", "Application": "Εφαρμογή", "Artists": "Καλλιτέχνες", @@ -15,8 +15,7 @@ "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", - "HeaderAlbumArtists": "Καλλιτέχνες του Άλμπουμ", - "HeaderCameraUploads": "Μεταφορτώσεις Κάμερας", + "HeaderAlbumArtists": "Άλμπουμ Καλλιτέχνη", "HeaderContinueWatching": "Συνεχίστε την παρακολούθηση", "HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ", "HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες", @@ -40,7 +39,7 @@ "MixedContent": "Ανάμεικτο Περιεχόμενο", "Movies": "Ταινίες", "Music": "Μουσική", - "MusicVideos": "Μουσικά βίντεο", + "MusicVideos": "Μουσικά Βίντεο", "NameInstallFailed": "{0} η εγκατάσταση απέτυχε", "NameSeasonNumber": "Κύκλος {0}", "NameSeasonUnknown": "Άγνωστος Κύκλος", @@ -63,7 +62,7 @@ "NotificationOptionVideoPlaybackStopped": "Η αναπαραγωγή βίντεο σταμάτησε", "Photos": "Φωτογραφίες", "Playlists": "Λίστες αναπαραγωγής", - "Plugin": "Plugin", + "Plugin": "Πρόσθετο", "PluginInstalledWithName": "{0} εγκαταστήθηκε", "PluginUninstalledWithName": "{0} έχει απεγκατασταθεί", "PluginUpdatedWithName": "{0} έχει αναβαθμιστεί", @@ -114,5 +113,12 @@ "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή", "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.", "TaskUpdatePlugins": "Ενημέρωση Προσθηκών", - "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας." + "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.", + "TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής παλαιότερες από την επιλεγμένη ηλικία.", + "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων", + "Undefined": "Απροσδιόριστο", + "Forced": "Εξαναγκασμένο", + "Default": "Προεπιλογή", + "TaskOptimizeDatabaseDescription": "Συμπιέζει τη βάση δεδομένων και δημιουργεί ελεύθερο χώρο. Η εκτέλεση αυτής της εργασίας μετά τη σάρωση της βιβλιοθήκης ή την πραγματοποίηση άλλων αλλαγών που συνεπάγονται τροποποιήσεις της βάσης δεδομένων μπορεί να βελτιώσει την απόδοση.", + "TaskOptimizeDatabase": "Βελτιστοποίηση βάσης δεδομένων" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 544c38cfa..8b2e8b6b1 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -16,7 +16,6 @@ "Folders": "Folders", "Genres": "Genres", "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", "HeaderContinueWatching": "Continue Watching", "HeaderFavoriteAlbums": "Favourite Albums", "HeaderFavoriteArtists": "Favourite Artists", @@ -114,5 +113,12 @@ "TasksChannelsCategory": "Internet Channels", "TasksApplicationCategory": "Application", "TasksLibraryCategory": "Library", - "TasksMaintenanceCategory": "Maintenance" + "TasksMaintenanceCategory": "Maintenance", + "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.", + "TaskCleanActivityLog": "Clean Activity Log", + "Undefined": "Undefined", + "Forced": "Forced", + "Default": "Default", + "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.", + "TaskOptimizeDatabase": "Optimise database" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 97a843160..ca127cdb8 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -9,14 +9,15 @@ "Channels": "Channels", "ChapterNameValue": "Chapter {0}", "Collections": "Collections", + "Default": "Default", "DeviceOfflineWithName": "{0} has disconnected", "DeviceOnlineWithName": "{0} is connected", "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", "Favorites": "Favorites", "Folders": "Folders", + "Forced": "Forced", "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", + "HeaderAlbumArtists": "Artist's Album", "HeaderContinueWatching": "Continue Watching", "HeaderFavoriteAlbums": "Favorite Albums", "HeaderFavoriteArtists": "Favorite Artists", @@ -26,7 +27,7 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Next Up", "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", + "HomeVideos": "Home Videos", "Inherit": "Inherit", "ItemAddedWithName": "{0} was added to the library", "ItemRemovedWithName": "{0} was removed from the library", @@ -40,7 +41,7 @@ "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", - "MusicVideos": "Music videos", + "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", @@ -78,6 +79,7 @@ "Sync": "Sync", "System": "System", "TvShows": "TV Shows", + "Undefined": "Undefined", "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", @@ -96,6 +98,8 @@ "TasksLibraryCategory": "Library", "TasksApplicationCategory": "Application", "TasksChannelsCategory": "Internet Channels", + "TaskCleanActivityLog": "Clean Activity Log", + "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.", "TaskCleanCache": "Clean Cache Directory", "TaskCleanCacheDescription": "Deletes cache files no longer needed by the system.", "TaskRefreshChapterImages": "Extract Chapter Images", @@ -113,5 +117,7 @@ "TaskRefreshChannels": "Refresh Channels", "TaskRefreshChannelsDescription": "Refreshes internet channel information.", "TaskDownloadMissingSubtitles": "Download missing subtitles", - "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration." + "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration.", + "TaskOptimizeDatabase": "Optimize database", + "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance." } diff --git a/Emby.Server.Implementations/Localization/Core/eo.json b/Emby.Server.Implementations/Localization/Core/eo.json new file mode 100644 index 000000000..ca615cc8c --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -0,0 +1,47 @@ +{ + "NotificationOptionInstallationFailed": "Instalada fiasko", + "NotificationOptionAudioPlaybackStopped": "Sono de ludado haltis", + "NotificationOptionAudioPlayback": "Ludado de sono startis", + "NameSeasonUnknown": "Sezono Nekonata", + "NameSeasonNumber": "Sezono {0}", + "NameInstallFailed": "{0} instalado fiaskis", + "Music": "Muziko", + "Movies": "Filmoj", + "ItemRemovedWithName": "{0} forigis el la biblioteko", + "ItemAddedWithName": "{0} aldonis al la biblioteko", + "HeaderLiveTV": "Viva Televido", + "HeaderContinueWatching": "Daŭrigi Spektado", + "HeaderAlbumArtists": "Artistoj de Albumo", + "Folders": "Dosierujoj", + "DeviceOnlineWithName": "{0} estas konektita", + "Default": "Defaŭlte", + "Collections": "Kolektoj", + "ChapterNameValue": "Ĉapitro {0}", + "Channels": "Kanaloj", + "Books": "Libroj", + "Artists": "Artistoj", + "Application": "Aplikaĵo", + "AppDeviceValues": "Aplikaĵo: {0}, Aparato: {1}", + "Albums": "Albumoj", + "TasksLibraryCategory": "Libraro", + "VersionNumber": "Versio {0}", + "UserDownloadingItemWithValues": "{0} elŝutas {1}", + "UserCreatedWithName": "Uzanto {0} kreiĝis", + "User": "Uzanto", + "System": "Sistemo", + "Songs": "Kantoj", + "ScheduledTaskStartedWithName": "{0} komencis", + "ScheduledTaskFailedWithName": "{0} malsukcesis", + "PluginUninstalledWithName": "{0} malinstaliĝis", + "PluginInstalledWithName": "{0} instaliĝis", + "Plugin": "Kromprogramo", + "Playlists": "Ludlistoj", + "Photos": "Fotoj", + "NotificationOptionPluginUninstalled": "Kromprogramo malinstaliĝis", + "NotificationOptionNewLibraryContent": "Nova enhavo aldoniĝis", + "NotificationOptionPluginInstalled": "Kromprogramo instaliĝis", + "MusicVideos": "Muzikvideoj", + "LabelIpAddressValue": "IP-adreso: {0}", + "Genres": "Ĝenroj", + "DeviceOfflineWithName": "{0} malkonektis" +} diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index ac96c788c..6321f695c 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -16,7 +16,6 @@ "Folders": "Carpetas", "Genres": "Géneros", "HeaderAlbumArtists": "Artistas de álbum", - "HeaderCameraUploads": "Subidas de cámara", "HeaderContinueWatching": "Seguir viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", @@ -114,5 +113,12 @@ "TasksChannelsCategory": "Canales de internet", "TasksApplicationCategory": "Aplicación", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Mantenimiento" + "TasksMaintenanceCategory": "Mantenimiento", + "TaskCleanActivityLogDescription": "Borrar log de actividades anteriores a la fecha establecida.", + "TaskCleanActivityLog": "Borrar log de actividades", + "Undefined": "Indefinido", + "Forced": "Forzado", + "Default": "Por Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", + "TaskOptimizeDatabase": "Optimización de base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index 4ba324aa1..432814dac 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -15,8 +15,7 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", - "HeaderCameraUploads": "Subidas desde la cámara", + "HeaderAlbumArtists": "Artistas del Álbum", "HeaderContinueWatching": "Continuar viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", @@ -26,7 +25,7 @@ "HeaderLiveTV": "TV en vivo", "HeaderNextUp": "A continuación", "HeaderRecordingGroups": "Grupos de grabación", - "HomeVideos": "Videos caseros", + "HomeVideos": "Videos Caseros", "Inherit": "Heredar", "ItemAddedWithName": "{0} fue agregado a la biblioteca", "ItemRemovedWithName": "{0} fue removido de la biblioteca", @@ -40,7 +39,7 @@ "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", - "MusicVideos": "Videos musicales", + "MusicVideos": "Videos Musicales", "NameInstallFailed": "Falló la instalación de {0}", "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada desconocida", @@ -50,7 +49,7 @@ "NotificationOptionAudioPlayback": "Reproducción de audio iniciada", "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida", "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", - "NotificationOptionInstallationFailed": "Falla de instalación", + "NotificationOptionInstallationFailed": "Fallo en la instalación", "NotificationOptionNewLibraryContent": "Nuevo contenido agregado", "NotificationOptionPluginError": "Falla de complemento", "NotificationOptionPluginInstalled": "Complemento instalado", @@ -70,7 +69,7 @@ "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", "ScheduledTaskStartedWithName": "{0} iniciado", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", + "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Programas", "Songs": "Canciones", "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", @@ -95,9 +94,9 @@ "VersionNumber": "Versión {0}", "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", - "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.", + "TaskRefreshChannelsDescription": "Actualiza la información de los canales de Internet.", "TaskRefreshChannels": "Actualizar canales", - "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.", + "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día de antigüedad.", "TaskCleanTranscode": "Limpiar directorio de transcodificado", "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para complementos que están configurados para actualizarse automáticamente.", "TaskUpdatePlugins": "Actualizar complementos", @@ -114,5 +113,12 @@ "TasksChannelsCategory": "Canales de Internet", "TasksApplicationCategory": "Aplicación", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Mantenimiento" + "TasksMaintenanceCategory": "Mantenimiento", + "TaskCleanActivityLogDescription": "Elimina entradas del registro de actividad que sean más antiguas al periodo establecido.", + "TaskCleanActivityLog": "Limpiar registro de actividades", + "Undefined": "Sin definir", + "Forced": "Forzado", + "Default": "Predeterminado", + "TaskOptimizeDatabase": "Optimizar base de datos", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y trunca el espacio libre. Puede mejorar el rendimiento si se realiza esta tarea después de escanear la biblioteca o después de realizar otros cambios que impliquen modificar la base de datos." } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index e7bd3959b..d3d9d2703 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -15,8 +15,7 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", - "HeaderCameraUploads": "Subidas desde la cámara", + "HeaderAlbumArtists": "Artista del álbum", "HeaderContinueWatching": "Continuar viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", @@ -71,14 +70,14 @@ "ScheduledTaskFailedWithName": "{0} falló", "ScheduledTaskStartedWithName": "{0} iniciada", "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", - "Shows": "Mostrar", + "Shows": "Series", "Songs": "Canciones", "StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.", "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}", "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", - "TvShows": "Programas de televisión", + "TvShows": "Series", "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", @@ -114,5 +113,12 @@ "TaskRefreshChannels": "Actualizar canales", "TaskRefreshChannelsDescription": "Actualiza la información de los canales de internet.", "TaskDownloadMissingSubtitles": "Descargar los subtítulos que faltan", - "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos." + "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos.", + "TaskCleanActivityLogDescription": "Elimina todos los registros de actividad anteriores a la fecha configurada.", + "TaskCleanActivityLog": "Limpiar registro de actividad", + "Undefined": "Indefinido", + "Forced": "Forzado", + "Default": "Predeterminado", + "TaskOptimizeDatabase": "Optimizar la base de datos", + "TaskOptimizeDatabaseDescription": "Compacta y libera el espacio libre en la base de datos. Ejecutar esta tarea tras escanear la biblioteca o hacer cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento." } diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index 0959ef2ca..a968c6dab 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -105,7 +105,6 @@ "Inherit": "Heredar", "HomeVideos": "Videos caseros", "HeaderRecordingGroups": "Grupos de grabación", - "HeaderCameraUploads": "Subidas desde la cámara", "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}", "DeviceOnlineWithName": "{0} está conectado", "DeviceOfflineWithName": "{0} se ha desconectado", @@ -113,5 +112,12 @@ "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", + "Default": "Por Defecto", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.", + "TaskOptimizeDatabase": "Optimización de base de datos" } diff --git a/Emby.Server.Implementations/Localization/Core/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json index 0ef16542f..b64ffbfbb 100644 --- a/Emby.Server.Implementations/Localization/Core/es_DO.json +++ b/Emby.Server.Implementations/Localization/Core/es_DO.json @@ -12,10 +12,12 @@ "Application": "Aplicación", "AppDeviceValues": "App: {0}, Dispositivo: {1}", "HeaderContinueWatching": "Continuar Viendo", - "HeaderCameraUploads": "Subidas de Cámara", "HeaderAlbumArtists": "Artistas del Álbum", "Genres": "Géneros", "Folders": "Carpetas", "Favorites": "Favoritos", - "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido de {0}" + "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido de {0}", + "HeaderFavoriteSongs": "Canciones Favoritas", + "HeaderFavoriteEpisodes": "Episodios Favoritos", + "HeaderFavoriteArtists": "Artistas Favoritos" } diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 500c29217..8ab657e5b 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -16,7 +16,6 @@ "Folders": "پوشهها", "Genres": "ژانرها", "HeaderAlbumArtists": "هنرمندان آلبوم", - "HeaderCameraUploads": "آپلودهای دوربین", "HeaderContinueWatching": "ادامه تماشا", "HeaderFavoriteAlbums": "آلبومهای مورد علاقه", "HeaderFavoriteArtists": "هنرمندان مورد علاقه", @@ -35,7 +34,7 @@ "Latest": "جدیدترینها", "MessageApplicationUpdated": "سرور Jellyfin بروزرسانی شد", "MessageApplicationUpdatedTo": "سرور Jellyfin به نسخه {0} بروزرسانی شد", - "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", + "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", "MessageServerConfigurationUpdated": "پیکربندی سرور بروزرسانی شد", "MixedContent": "محتوای مخلوط", "Movies": "فیلمها", @@ -50,7 +49,7 @@ "NotificationOptionAudioPlayback": "پخش صدا آغاز شد", "NotificationOptionAudioPlaybackStopped": "پخش صدا متوقف شد", "NotificationOptionCameraImageUploaded": "تصاویر دوربین آپلود شد", - "NotificationOptionInstallationFailed": "نصب شکست خورد", + "NotificationOptionInstallationFailed": "نصب ناموفق", "NotificationOptionNewLibraryContent": "محتوای جدید افزوده شد", "NotificationOptionPluginError": "خرابی افزونه", "NotificationOptionPluginInstalled": "افزونه نصب شد", @@ -114,5 +113,10 @@ "TasksChannelsCategory": "کانالهای داخلی", "TasksApplicationCategory": "برنامه", "TasksLibraryCategory": "کتابخانه", - "TasksMaintenanceCategory": "تعمیر" + "TasksMaintenanceCategory": "تعمیر", + "Forced": "اجباری", + "Default": "پیشفرض", + "TaskCleanActivityLogDescription": "ورودیهای قدیمیتر از سن تنظیم شده در سیاهه فعالیت را حذف میکند.", + "TaskCleanActivityLog": "پاکسازی سیاهه فعالیت", + "Undefined": "تعریف نشده" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index f8d6e0e09..4a1f4f1d5 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -1,117 +1,123 @@ { - "HeaderLiveTV": "Live-TV", - "NewVersionIsAvailable": "Uusi versio Jellyfin palvelimesta on ladattavissa.", - "NameSeasonUnknown": "Tuntematon Kausi", + "HeaderLiveTV": "Suora TV", + "NewVersionIsAvailable": "Uusi versio Jellyfin-palvelimesta on ladattavissa.", + "NameSeasonUnknown": "Tuntematon kausi", "NameSeasonNumber": "Kausi {0}", "NameInstallFailed": "{0} asennus epäonnistui", "MusicVideos": "Musiikkivideot", "Music": "Musiikki", "Movies": "Elokuvat", - "MixedContent": "Sekoitettu sisältö", + "MixedContent": "Sekalainen sisältö", "MessageServerConfigurationUpdated": "Palvelimen asetukset on päivitetty", - "MessageNamedServerConfigurationUpdatedWithValue": "Palvelimen asetusryhmä {0} on päivitetty", - "MessageApplicationUpdatedTo": "Jellyfin palvelin on päivitetty versioon {0}", - "MessageApplicationUpdated": "Jellyfin palvelin on päivitetty", - "Latest": "Uusimmat", - "LabelRunningTimeValue": "Toiston kesto: {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Palvelimen asetusten osio {0} on päivitetty", + "MessageApplicationUpdatedTo": "Jellyfin-palvelin on päivitetty versioon {0}", + "MessageApplicationUpdated": "Jellyfin-palvelin on päivitetty", + "Latest": "Viimeisimmät", + "LabelRunningTimeValue": "Kesto: {0}", "LabelIpAddressValue": "IP-osoite: {0}", "ItemRemovedWithName": "{0} poistettiin kirjastosta", "ItemAddedWithName": "{0} lisättiin kirjastoon", - "Inherit": "Periytyä", + "Inherit": "Peri", "HomeVideos": "Kotivideot", - "HeaderRecordingGroups": "Nauhoiteryhmät", + "HeaderRecordingGroups": "Tallennusryhmät", "HeaderNextUp": "Seuraavaksi", - "HeaderFavoriteSongs": "Lempikappaleet", - "HeaderFavoriteShows": "Lempisarjat", - "HeaderFavoriteEpisodes": "Lempijaksot", - "HeaderCameraUploads": "Kamerasta Lähetetyt", - "HeaderFavoriteArtists": "Lempiartistit", - "HeaderFavoriteAlbums": "Lempialbumit", - "HeaderContinueWatching": "Jatka katsomista", - "HeaderAlbumArtists": "Albumin esittäjä", + "HeaderFavoriteSongs": "Suosikkikappaleet", + "HeaderFavoriteShows": "Suosikkisarjat", + "HeaderFavoriteEpisodes": "Suosikkijaksot", + "HeaderFavoriteArtists": "Suosikkiesittäjät", + "HeaderFavoriteAlbums": "Suosikkialbumit", + "HeaderContinueWatching": "Jatka katselua", + "HeaderAlbumArtists": "Albumin esittäjät", "Genres": "Tyylilajit", "Folders": "Kansiot", "Favorites": "Suosikit", - "FailedLoginAttemptWithUserName": "Kirjautuminen epäonnistui kohteesta {0}", + "FailedLoginAttemptWithUserName": "Epäonnistunut kirjautumisyritys lähteestä \"{0}\"", "DeviceOnlineWithName": "{0} on yhdistetty", - "DeviceOfflineWithName": "{0} on katkaissut yhteytensä", + "DeviceOfflineWithName": "{0} on katkaissut yhteyden", "Collections": "Kokoelmat", - "ChapterNameValue": "Luku: {0}", + "ChapterNameValue": "Kappale {0}", "Channels": "Kanavat", - "CameraImageUploadedFrom": "Uusi kamerakuva on ladattu {0}", + "CameraImageUploadedFrom": "Uusi kameran kuva on sirretty lähteestä {0}", "Books": "Kirjat", - "AuthenticationSucceededWithUserName": "{0} todennus onnistui", - "Artists": "Artistit", + "AuthenticationSucceededWithUserName": "{0} on todennettu", + "Artists": "Esittäjät", "Application": "Sovellus", "AppDeviceValues": "Sovellus: {0}, Laite: {1}", "Albums": "Albumit", "User": "Käyttäjä", "System": "Järjestelmä", "ScheduledTaskFailedWithName": "{0} epäonnistui", - "PluginUpdatedWithName": "{0} päivitetty", - "PluginInstalledWithName": "{0} asennettu", - "Photos": "Kuvat", - "ScheduledTaskStartedWithName": "{0} aloitettu", - "PluginUninstalledWithName": "{0} poistettu", + "PluginUpdatedWithName": "{0} päivitettiin", + "PluginInstalledWithName": "{0} asennettiin", + "Photos": "Valokuvat", + "ScheduledTaskStartedWithName": "\"{0}\" käynnistetty", + "PluginUninstalledWithName": "{0} poistettiin", "Playlists": "Soittolistat", "VersionNumber": "Versio {0}", - "ValueSpecialEpisodeName": "Erikois - {0}", - "ValueHasBeenAddedToLibrary": "{0} lisättiin mediakirjastoon", - "UserStoppedPlayingItemWithValues": "{0} toistaminen valmistui {1} laitteella {2}", - "UserStartedPlayingItemWithValues": "{0} toistaa {1} laitteella {2}", - "UserPolicyUpdatedWithName": "Käyttöoikeudet päivitetty käyttäjälle {0}", - "UserPasswordChangedWithName": "Salasana vaihdettu käyttäjälle {0}", - "UserOnlineFromDevice": "{0} on paikalla osoitteesta {1}", - "UserOfflineFromDevice": "{0} yhteys katkaistu {1}", - "UserLockedOutWithName": "Käyttäjä {0} lukittu", - "UserDownloadingItemWithValues": "{0} lataa {1}", - "UserDeletedWithName": "Käyttäjä {0} poistettu", - "UserCreatedWithName": "Käyttäjä {0} luotu", - "TvShows": "TV-sarjat", - "Sync": "Synkronoi", - "SubtitleDownloadFailureFromForItem": "Tekstitysten lataus ({0} -> {1}) epäonnistui //this string would have to be generated for each provider and movie because of finnish cases, sorry", - "StartupEmbyServerIsLoading": "Jellyfin palvelin latautuu. Kokeile hetken kuluttua uudelleen.", + "ValueSpecialEpisodeName": "Erikoisjakso - {0}", + "ValueHasBeenAddedToLibrary": "\"{0}\" on lisätty mediakirjastoon", + "UserStoppedPlayingItemWithValues": "{0} lopetti kohteen \"{1}\" toiston sijainnissa \"{2}\"", + "UserStartedPlayingItemWithValues": "{0} toistaa kohdetta \"{1}\" sijainnissa \"{2}\"", + "UserPolicyUpdatedWithName": "Käyttäjän {0} käyttöoikeudet on päivitetty", + "UserPasswordChangedWithName": "Käyttäjän {0} salasana on vaihdettu", + "UserOnlineFromDevice": "{0} on yhdistänyt sijainnista \"{1}\"", + "UserOfflineFromDevice": "{0} on katkaissut yhteyden sijainnista \"{1}\"", + "UserLockedOutWithName": "Käyttäjä {0} on lukittu", + "UserDownloadingItemWithValues": "{0} lataa kohdetta \"{1}\"", + "UserDeletedWithName": "Käyttäjä {0} on poistettu", + "UserCreatedWithName": "Käyttäjä {0} on luotu", + "TvShows": "Sarjat", + "Sync": "Synkronointi", + "SubtitleDownloadFailureFromForItem": "Tekstityksen lataus lähteestä \"{0}\" kohteelle \"{1}\" epäonnistui", + "StartupEmbyServerIsLoading": "Jellyfin-palvelin latautuu. Yritä hetken kuluttua uudelleen.", "Songs": "Kappaleet", "Shows": "Sarjat", - "ServerNameNeedsToBeRestarted": "{0} täytyy käynnistää uudelleen", - "ProviderValue": "Tarjoaja: {0}", - "Plugin": "Liitännäinen", - "NotificationOptionVideoPlaybackStopped": "Videon toisto pysäytetty", - "NotificationOptionVideoPlayback": "Videota toistetaan", - "NotificationOptionUserLockedOut": "Käyttäjä kirjautui ulos", - "NotificationOptionTaskFailed": "Ajastettu tehtävä epäonnistui", - "NotificationOptionServerRestartRequired": "Palvelin pitää käynnistää uudelleen", - "NotificationOptionPluginUpdateInstalled": "Liitännäinen päivitetty", - "NotificationOptionPluginUninstalled": "Liitännäinen poistettu", - "NotificationOptionPluginInstalled": "Liitännäinen asennettu", - "NotificationOptionPluginError": "Ongelma liitännäisessä", - "NotificationOptionNewLibraryContent": "Uutta sisältöä lisätty", + "ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen", + "ProviderValue": "Lähde: {0}", + "Plugin": "Laajennus", + "NotificationOptionVideoPlaybackStopped": "Videon toisto lopetettu", + "NotificationOptionVideoPlayback": "Videon toisto aloitettu", + "NotificationOptionUserLockedOut": "Käyttäjä on lukittu", + "NotificationOptionTaskFailed": "Ajoitettu tehtävä epäonnistui", + "NotificationOptionServerRestartRequired": "Tarvitaan palvelimen uudelleenkäynnistys", + "NotificationOptionPluginUpdateInstalled": "Laajennus on päivitetty", + "NotificationOptionPluginUninstalled": "Laajennus on poistettu", + "NotificationOptionPluginInstalled": "Laajennus on asennettu", + "NotificationOptionPluginError": "Laajennuksen virhe", + "NotificationOptionNewLibraryContent": "Sisältöä on lisätty", "NotificationOptionInstallationFailed": "Asennus epäonnistui", - "NotificationOptionCameraImageUploaded": "Kameran kuva ladattu", + "NotificationOptionCameraImageUploaded": "Kameran kuva on tallennettu", "NotificationOptionAudioPlaybackStopped": "Äänen toisto lopetettu", - "NotificationOptionAudioPlayback": "Toistetaan ääntä", - "NotificationOptionApplicationUpdateInstalled": "Sovelluspäivitys asennettu", - "NotificationOptionApplicationUpdateAvailable": "Ohjelmistopäivitys saatavilla", + "NotificationOptionAudioPlayback": "Äänen toisto aloitettu", + "NotificationOptionApplicationUpdateInstalled": "Sovelluspäivitys asennettiin", + "NotificationOptionApplicationUpdateAvailable": "Sovelluspäivitys on saatavilla", "TasksMaintenanceCategory": "Ylläpito", - "TaskDownloadMissingSubtitlesDescription": "Etsii puuttuvia tekstityksiä videon metadatatietojen pohjalta.", + "TaskDownloadMissingSubtitlesDescription": "Etsii puuttuvia tekstityksiä määritettyjen metatietoasetusten mukaisesti.", "TaskDownloadMissingSubtitles": "Lataa puuttuvat tekstitykset", "TaskRefreshChannelsDescription": "Päivittää internet-kanavien tiedot.", "TaskRefreshChannels": "Päivitä kanavat", - "TaskCleanTranscodeDescription": "Poistaa transkoodatut tiedostot jotka ovat yli päivän vanhoja.", - "TaskCleanTranscode": "Puhdista transkoodaushakemisto", - "TaskUpdatePluginsDescription": "Lataa ja asentaa päivitykset liitännäisille jotka on asetettu päivittymään automaattisesti.", - "TaskUpdatePlugins": "Päivitä liitännäiset", - "TaskRefreshPeopleDescription": "Päivittää näyttelijöiden ja ohjaajien mediatiedot kirjastossasi.", + "TaskCleanTranscodeDescription": "Poistaa päivää vanhemmat transkoodaustiedostot.", + "TaskCleanTranscode": "Puhdista transkoodauskansio", + "TaskUpdatePluginsDescription": "Lataa ja asentaa päivitykset laajennuksille, jotka on määritetty päivittymään automaattisesti.", + "TaskUpdatePlugins": "Päivitä laajennukset", + "TaskRefreshPeopleDescription": "Päivittää mediakirjaston näyttelijöiden ja ohjaajien metatiedot.", "TaskRefreshPeople": "Päivitä henkilöt", - "TaskCleanLogsDescription": "Poistaa lokitiedostot jotka ovat yli {0} päivää vanhoja.", - "TaskCleanLogs": "Puhdista lokihakemisto", - "TaskRefreshLibraryDescription": "Skannaa mediakirjastosi uusien tiedostojen varalle, sekä virkistää metatiedot.", - "TaskRefreshLibrary": "Skannaa mediakirjasto", - "TaskRefreshChapterImagesDescription": "Luo pienoiskuvat videoille joissa on lukuja.", - "TaskRefreshChapterImages": "Eristä lukujen kuvat", - "TaskCleanCacheDescription": "Poistaa järjestelmälle tarpeettomat väliaikaistiedostot.", - "TaskCleanCache": "Tyhjennä välimuisti-hakemisto", - "TasksChannelsCategory": "Internet kanavat", + "TaskCleanLogsDescription": "Poistaa {0} päivää vanhemmat lokitiedostot.", + "TaskCleanLogs": "Siivoa lokikansio", + "TaskRefreshLibraryDescription": "Tarkastaa mediakirjastosi sisällön uusien tiedostojen varalta ja päivittää metatiedot.", + "TaskRefreshLibrary": "Päivitä mediakirjasto", + "TaskRefreshChapterImagesDescription": "Luo esikatselukuvat videoille, jotka sisältävät kappalejaon.", + "TaskRefreshChapterImages": "Pura kappalejaon kuvat", + "TaskCleanCacheDescription": "Poistaa tarpeettomiksi jääneet väliaikaistiedostot.", + "TaskCleanCache": "Tyhjennä välimuistikansio", + "TasksChannelsCategory": "Internet-kanavat", "TasksApplicationCategory": "Sovellus", - "TasksLibraryCategory": "Kirjasto" + "TasksLibraryCategory": "Kirjasto", + "Forced": "Pakotettu", + "Default": "Oletus", + "TaskCleanActivityLogDescription": "Poistaa määritettyä ikää vanhemmat tapahtumat toimintahistoriasta.", + "TaskCleanActivityLog": "Tyhjennä toimintahistoria", + "Undefined": "Määrittelemätön", + "TaskOptimizeDatabaseDescription": "Tiivistää ja puhdistaa tietokannan. Tämän toiminnon suorittaminen kirjastojen skannauksen tai muiden tietokantaan liittyvien muutoksien jälkeen voi parantaa suorituskykyä.", + "TaskOptimizeDatabase": "Optimoi tietokanta" } diff --git a/Emby.Server.Implementations/Localization/Core/fil.json b/Emby.Server.Implementations/Localization/Core/fil.json index 47daf2044..f18a1c030 100644 --- a/Emby.Server.Implementations/Localization/Core/fil.json +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -1,102 +1,121 @@ { "VersionNumber": "Bersyon {0}", "ValueSpecialEpisodeName": "Espesyal - {0}", - "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong media library", + "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong librerya ng medya", "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}", - "UserStartedPlayingItemWithValues": "Si {0} ay nagplaplay ng {1} sa {2}", - "UserPolicyUpdatedWithName": "Ang user policy ay naiupdate para kay {0}", + "UserStartedPlayingItemWithValues": "Si {0} ay nagpla-play ng {1} sa {2}", + "UserPolicyUpdatedWithName": "Ang user policy ay nai-update para kay {0}", "UserPasswordChangedWithName": "Napalitan na ang password ni {0}", - "UserOnlineFromDevice": "Si {0} ay nakakonekta galing sa {1}", - "UserOfflineFromDevice": "Si {0} ay nadiskonekta galing sa {1}", + "UserOnlineFromDevice": "Si {0} ay naka-konekta galing sa {1}", + "UserOfflineFromDevice": "Si {0} ay na-diskonekta galing sa {1}", "UserLockedOutWithName": "Si {0} ay nalock out", "UserDownloadingItemWithValues": "Nagdadownload si {0} ng {1}", "UserDeletedWithName": "Natanggal na is user {0}", "UserCreatedWithName": "Nagawa na si user {0}", "User": "User", - "TvShows": "Pelikula", + "TvShows": "Mga Palabas sa Telebisyon", "System": "Sistema", "Sync": "Pag-sync", - "SubtitleDownloadFailureFromForItem": "Hindi naidownload ang subtitles {0} para sa {1}", - "StartupEmbyServerIsLoading": "Nagloload ang Jellyfin Server. Sandaling maghintay.", - "Songs": "Kanta", - "Shows": "Pelikula", + "SubtitleDownloadFailureFromForItem": "Hindi nai-download ang subtitles {0} para sa {1}", + "StartupEmbyServerIsLoading": "Naglo-load ang Jellyfin Server. Mangyaring subukan ulit sandali.", + "Songs": "Mga Kanta", + "Shows": "Mga Pelikula", "ServerNameNeedsToBeRestarted": "Kailangan irestart ang {0}", "ScheduledTaskStartedWithName": "Nagsimula na ang {0}", - "ScheduledTaskFailedWithName": "Hindi gumana and {0}", - "ProviderValue": "Ang provider ay {0}", + "ScheduledTaskFailedWithName": "Hindi gumana ang {0}", + "ProviderValue": "Tagapagtustos: {0}", "PluginUpdatedWithName": "Naiupdate na ang {0}", "PluginUninstalledWithName": "Naiuninstall na ang {0}", "PluginInstalledWithName": "Nainstall na ang {0}", "Plugin": "Plugin", - "Playlists": "Playlists", - "Photos": "Larawan", + "Playlists": "Mga Playlist", + "Photos": "Mga Larawan", "NotificationOptionVideoPlaybackStopped": "Huminto na ang pelikula", "NotificationOptionVideoPlayback": "Nagsimula na ang pelikula", - "NotificationOptionUserLockedOut": "Nakalock out ang user", + "NotificationOptionUserLockedOut": "Naka-lock out ang user", "NotificationOptionTaskFailed": "Hindi gumana ang scheduled task", - "NotificationOptionServerRestartRequired": "Kailangan irestart ang server", - "NotificationOptionPluginUpdateInstalled": "Naiupdate na ang plugin", - "NotificationOptionPluginUninstalled": "Naiuninstall na ang plugin", + "NotificationOptionServerRestartRequired": "Kailangan i-restart ang server", + "NotificationOptionPluginUpdateInstalled": "Nai-update na ang plugin", + "NotificationOptionPluginUninstalled": "Nai-uninstall na ang plugin", "NotificationOptionPluginInstalled": "Nainstall na ang plugin", "NotificationOptionPluginError": "Hindi gumagana ang plugin", "NotificationOptionNewLibraryContent": "May bagong content na naidagdag", "NotificationOptionInstallationFailed": "Hindi nainstall ng mabuti", - "NotificationOptionCameraImageUploaded": "Naiupload na ang picture", + "NotificationOptionCameraImageUploaded": "Naiupload na ang litrato", "NotificationOptionAudioPlaybackStopped": "Huminto na ang patugtog", "NotificationOptionAudioPlayback": "Nagsimula na ang patugtog", "NotificationOptionApplicationUpdateInstalled": "Naiupdate na ang aplikasyon", "NotificationOptionApplicationUpdateAvailable": "May bagong update ang aplikasyon", - "NewVersionIsAvailable": "May bagong version ng Jellyfin Server na pwede idownload.", - "NameSeasonUnknown": "Hindi alam ang season", + "NewVersionIsAvailable": "May bagong version ng Jellyfin Server na pwede i-download.", + "NameSeasonUnknown": "Hindi matukoy ang season", "NameSeasonNumber": "Season {0}", "NameInstallFailed": "Hindi nainstall ang {0}", - "MusicVideos": "Music video", - "Music": "Kanta", - "Movies": "Pelikula", + "MusicVideos": "Mga Music video", + "Music": "Mga Kanta", + "Movies": "Mga Pelikula", "MixedContent": "Halo-halong content", "MessageServerConfigurationUpdated": "Naiupdate na ang server configuration", "MessageNamedServerConfigurationUpdatedWithValue": "Naiupdate na ang server configuration section {0}", - "MessageApplicationUpdatedTo": "Ang Jellyfin Server ay naiupdate to {0}", + "MessageApplicationUpdatedTo": "Ang bersyon ng Jellyfin Server ay naiupdate sa {0}", "MessageApplicationUpdated": "Naiupdate na ang Jellyfin Server", "Latest": "Pinakabago", "LabelRunningTimeValue": "Oras: {0}", - "LabelIpAddressValue": "Ang IP Address ay {0}", - "ItemRemovedWithName": "Naitanggal ang {0} sa library", - "ItemAddedWithName": "Naidagdag ang {0} sa library", + "LabelIpAddressValue": "IP address: {0}", + "ItemRemovedWithName": "Naitanggal ang {0} sa librerya", + "ItemAddedWithName": "Naidagdag ang {0} sa librerya", "Inherit": "Manahin", "HeaderRecordingGroups": "Pagtatalang Grupo", "HeaderNextUp": "Susunod", "HeaderLiveTV": "Live TV", - "HeaderFavoriteSongs": "Paboritong Kanta", - "HeaderFavoriteShows": "Paboritong Pelikula", - "HeaderFavoriteEpisodes": "Paboritong Episodes", - "HeaderFavoriteArtists": "Paboritong Artista", - "HeaderFavoriteAlbums": "Paboritong Albums", - "HeaderContinueWatching": "Ituloy Manood", - "HeaderCameraUploads": "Camera Uploads", - "HeaderAlbumArtists": "Artista ng Album", - "Genres": "Kategorya", - "Folders": "Folders", - "Favorites": "Paborito", - "FailedLoginAttemptWithUserName": "maling login galing {0}", - "DeviceOnlineWithName": "nakakonekta si {0}", - "DeviceOfflineWithName": "nadiskonekta si {0}", - "Collections": "Koleksyon", + "HeaderFavoriteSongs": "Mga Paboritong Kanta", + "HeaderFavoriteShows": "Mga Paboritong Pelikula", + "HeaderFavoriteEpisodes": "Mga Paboritong Episode", + "HeaderFavoriteArtists": "Mga Paboritong Artista", + "HeaderFavoriteAlbums": "Mga Paboritong Album", + "HeaderContinueWatching": "Magpatuloy sa Panonood", + "HeaderAlbumArtists": "Mga Artista ng Album", + "Genres": "Mga Kategorya", + "Folders": "Mga Folder", + "Favorites": "Mga Paborito", + "FailedLoginAttemptWithUserName": "Maling login galing kay/sa {0}", + "DeviceOnlineWithName": "Nakakonekta si/ang {0}", + "DeviceOfflineWithName": "Nadiskonekta si/ang {0}", + "Collections": "Mga Koleksyon", "ChapterNameValue": "Kabanata {0}", - "Channels": "Channel", - "CameraImageUploadedFrom": "May bagong larawan na naupload galing {0}", - "Books": "Libro", - "AuthenticationSucceededWithUserName": "{0} na patunayan", - "Artists": "Artista", + "Channels": "Mga Channel", + "CameraImageUploadedFrom": "May bagong larawan na naupload galing sa/kay {0}", + "Books": "Mga Libro", + "AuthenticationSucceededWithUserName": "Napatunayan si/ang {0}", + "Artists": "Mga Artista", "Application": "Aplikasyon", "AppDeviceValues": "Aplikasyon: {0}, Aparato: {1}", - "Albums": "Albums", - "TaskRefreshLibrary": "Suriin ang nasa librerya", - "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata", + "Albums": "Mga Album", + "TaskRefreshLibrary": "Suriin and Librerya ng Medya", + "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata.", "TaskRefreshChapterImages": "Kunin ang mga larawan ng kabanata", - "TaskCleanCacheDescription": "Tanggalin ang mga cache file na hindi na kailangan ng systema.", + "TaskCleanCacheDescription": "Tanggalin ang mga cache file na hindi na kailangan ng sistema.", "TasksChannelsCategory": "Palabas sa internet", "TasksLibraryCategory": "Librerya", "TasksMaintenanceCategory": "Pagpapanatili", - "HomeVideos": "Sariling pelikula" + "HomeVideos": "Sariling video/pelikula", + "TaskRefreshPeopleDescription": "Ini-update ang metadata para sa mga aktor at direktor sa iyong librerya ng medya.", + "TaskRefreshPeople": "I-refresh ang Tauhan", + "TaskDownloadMissingSubtitlesDescription": "Hinahanap sa internet ang mga nawawalang subtiles base sa metadata configuration.", + "TaskDownloadMissingSubtitles": "I-download and nawawalang subtitles", + "TaskRefreshChannelsDescription": "Ni-rerefresh ang impormasyon sa internet channels.", + "TaskRefreshChannels": "I-refresh ang Channels", + "TaskCleanTranscodeDescription": "Binubura ang transcode files na mas matanda ng isang araw.", + "TaskUpdatePluginsDescription": "Nag download at install ng updates sa plugins na naka configure para sa awtomatikong pag-update.", + "TaskUpdatePlugins": "I-update ang Plugins", + "TaskCleanLogsDescription": "Binubura and files ng talaan na mas mantanda ng {0} araw.", + "TaskCleanTranscode": "Linisin and Direktoryo ng Transcode", + "TaskCleanLogs": "Linisin and Direktoryo ng Talaan", + "TaskRefreshLibraryDescription": "Sinusuri ang iyong librerya ng medya para sa bagong files at irefresh ang metadata.", + "TaskCleanCache": "Linisin and Direktoryo ng Cache", + "TasksApplicationCategory": "Aplikasyon", + "TaskCleanActivityLog": "Linisin ang Tala ng Aktibidad", + "TaskCleanActivityLogDescription": "Tanggalin ang mga tala ng aktibidad na mas luma sa nakatakda na edad.", + "Default": "Default", + "Undefined": "Hindi tiyak", + "Forced": "Sapilitan" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index cd1c8144f..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,13 +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", - "HeaderCameraUploads": "Photos transférées", - "HeaderContinueWatching": "Continuer à regarder", + "HeaderContinueWatching": "Reprendre le visionnement", "HeaderFavoriteAlbums": "Albums favoris", "HeaderFavoriteArtists": "Artistes favoris", "HeaderFavoriteEpisodes": "Épisodes favoris", @@ -27,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", @@ -41,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", @@ -71,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", @@ -81,38 +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" + "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/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 47ebe1254..0e4c38425 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -15,8 +15,7 @@ "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Artistes", - "HeaderCameraUploads": "Photos transférées", + "HeaderAlbumArtists": "Artistes de l'album", "HeaderContinueWatching": "Continuer à regarder", "HeaderFavoriteAlbums": "Albums favoris", "HeaderFavoriteArtists": "Artistes préférés", @@ -40,7 +39,7 @@ "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", - "MusicVideos": "Vidéos musicales", + "MusicVideos": "Clips musicaux", "NameInstallFailed": "{0} échec de l'installation", "NameSeasonNumber": "Saison {0}", "NameSeasonUnknown": "Saison Inconnue", @@ -94,25 +93,32 @@ "ValueSpecialEpisodeName": "Spécial - {0}", "VersionNumber": "Version {0}", "TasksChannelsCategory": "Chaines en ligne", - "TaskDownloadMissingSubtitlesDescription": "Cherche les sous-titres manquant sur internet en se basant sur la configuration des métadonnées.", - "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquant", + "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquants sur internet en se basant sur la configuration des métadonnées.", + "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants", "TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines en ligne.", "TaskRefreshChannels": "Rafraîchir les chaines", "TaskCleanTranscodeDescription": "Supprime les fichiers transcodés de plus d'un jour.", "TaskCleanTranscode": "Nettoyer les dossier des transcodages", - "TaskUpdatePluginsDescription": "Télécharge et installe les mises à jours des extensions configurés pour être mises à jour automatiquement.", + "TaskUpdatePluginsDescription": "Télécharge et installe les mises à jours des extensions configurées pour être mises à jour automatiquement.", "TaskUpdatePlugins": "Mettre à jour les extensions", "TaskRefreshPeopleDescription": "Met à jour les métadonnées pour les acteurs et réalisateurs dans votre bibliothèque.", "TaskRefreshPeople": "Rafraîchir les acteurs", "TaskCleanLogsDescription": "Supprime les journaux de plus de {0} jours.", "TaskCleanLogs": "Nettoyer le répertoire des journaux", "TaskRefreshLibraryDescription": "Scanne toute les bibliothèques pour trouver les nouveaux fichiers et rafraîchit les métadonnées.", - "TaskRefreshLibrary": "Scanner toute les Bibliothèques", - "TaskRefreshChapterImagesDescription": "Crée des images de miniature pour les vidéos ayant des chapitres.", + "TaskRefreshLibrary": "Scanner toutes les Bibliothèques", + "TaskRefreshChapterImagesDescription": "Crée des vignettes pour les vidéos ayant des chapitres.", "TaskRefreshChapterImages": "Extraire les images de chapitre", "TaskCleanCacheDescription": "Supprime les fichiers de cache dont le système n'a plus besoin.", "TaskCleanCache": "Vider le répertoire cache", "TasksApplicationCategory": "Application", "TasksLibraryCategory": "Bibliothèque", - "TasksMaintenanceCategory": "Maintenance" + "TasksMaintenanceCategory": "Maintenance", + "TaskCleanActivityLogDescription": "Supprime les entrées du journal d'activité antérieures à l'âge configuré.", + "TaskCleanActivityLog": "Nettoyer le journal d'activité", + "Undefined": "Non défini", + "Forced": "Forcé", + "Default": "Par défaut", + "TaskOptimizeDatabaseDescription": "Réduit les espaces vides/inutiles et compacte la base de données. Utiliser cette fonction après une mise à jour de la bibliothèque ou toute autre modification de la base de données peut améliorer les performances du serveur.", + "TaskOptimizeDatabase": "Optimiser la base de données" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index 94034962d..afb22ab47 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -1,3 +1,121 @@ { - "Albums": "Álbumes" + "Albums": "Álbumes", + "Collections": "Colecións", + "ChapterNameValue": "Capítulos {0}", + "Channels": "Canles", + "CameraImageUploadedFrom": "Cargouse unha nova imaxe da cámara desde {0}", + "Books": "Libros", + "AuthenticationSucceededWithUserName": "{0} autenticouse correctamente", + "Artists": "Artistas", + "Application": "Aplicativo", + "NotificationOptionServerRestartRequired": "Necesario un reinicio do servidor", + "NotificationOptionPluginUpdateInstalled": "Actualización do Plugin instalada", + "NotificationOptionPluginUninstalled": "Plugin desinstalado", + "NotificationOptionPluginInstalled": "Plugin instalado", + "NotificationOptionPluginError": "Fallo do Plugin", + "NotificationOptionNewLibraryContent": "Novo contido engadido", + "NotificationOptionInstallationFailed": "Fallo na instalación", + "NotificationOptionCameraImageUploaded": "Imaxe da cámara subida", + "NotificationOptionAudioPlaybackStopped": "Reproducción de audio parada", + "NotificationOptionAudioPlayback": "Reproducción de audio comezada", + "NotificationOptionApplicationUpdateInstalled": "Actualización da aplicación instalada", + "NotificationOptionApplicationUpdateAvailable": "Actualización da aplicación dispoñible", + "NewVersionIsAvailable": "Unha nova versión do Servidor Jellyfin está dispoñible para descarga.", + "NameSeasonUnknown": "Tempada descoñecida", + "NameSeasonNumber": "Tempada {0}", + "NameInstallFailed": "{0} instalación fallida", + "MusicVideos": "Vídeos Musicais", + "Music": "Música", + "Movies": "Películas", + "MixedContent": "Contido Mixto", + "MessageServerConfigurationUpdated": "A configuración do servidor foi actualizada", + "MessageNamedServerConfigurationUpdatedWithValue": "A sección de configuración {0} do servidor foi actualizada", + "MessageApplicationUpdatedTo": "O servidor Jellyfin foi actualizado a {0}", + "MessageApplicationUpdated": "O servidor Jellyfin foi actualizado", + "Latest": "Último", + "LabelRunningTimeValue": "Tempo de execución: {0}", + "LabelIpAddressValue": "Enderezo IP: {0}", + "ItemRemovedWithName": "{0} foi eliminado da biblioteca", + "ItemAddedWithName": "{0} foi engadido a biblioteca", + "Inherit": "Herdar", + "HomeVideos": "Videos caseiros", + "HeaderRecordingGroups": "Grupos de Grabación", + "HeaderNextUp": "De seguido", + "HeaderLiveTV": "TV en directo", + "HeaderFavoriteSongs": "Cancións Favoritas", + "HeaderFavoriteShows": "Series de TV Favoritas", + "HeaderFavoriteEpisodes": "Episodios Favoritos", + "HeaderFavoriteArtists": "Artistas Favoritos", + "HeaderFavoriteAlbums": "Álbunes Favoritos", + "HeaderContinueWatching": "Seguir mirando", + "HeaderAlbumArtists": "Artistas de Album", + "Genres": "Xéneros", + "Forced": "Forzado", + "Folders": "Cartafoles", + "Favorites": "Favoritos", + "FailedLoginAttemptWithUserName": "Intento de incio de sesión fallido {0}", + "DeviceOnlineWithName": "{0} conectouse", + "DeviceOfflineWithName": "{0} desconectouse", + "Default": "Por defecto", + "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", + "TaskCleanLogs": "Limpar Carpeta de Rexistros", + "TaskCleanActivityLog": "Limpar Rexistro de Actividade", + "TasksChannelsCategory": "Canáis de Internet", + "TaskUpdatePlugins": "Actualizar Plugins", + "User": "Usuario", + "Undefined": "Sen definir", + "TvShows": "Programas de TV", + "System": "Sistema", + "Sync": "Sincronizar", + "SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}", + "StartupEmbyServerIsLoading": "O Servidor Jellyfin está cargando. Por favor, reinténteo en breve.", + "Songs": "Cancións", + "Shows": "Programas", + "ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado", + "ScheduledTaskStartedWithName": "{0} comezou", + "ScheduledTaskFailedWithName": "{0} fallou", + "ProviderValue": "Provedor: {0}", + "PluginUpdatedWithName": "{0} foi actualizado", + "PluginUninstalledWithName": "{0} foi desinstalado", + "PluginInstalledWithName": "{0} foi instalado", + "Playlists": "Listas de reproducción", + "Photos": "Fotos", + "UserLockedOutWithName": "O usuario {0} foi bloqueado", + "UserDownloadingItemWithValues": "{0} está a ser transferido {1}", + "UserDeletedWithName": "O usuario {0} foi borrado", + "UserCreatedWithName": "O usuario {0} foi creado", + "Plugin": "Plugin", + "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo parada", + "NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada", + "NotificationOptionUserLockedOut": "Usuario bloqueado", + "NotificationOptionTaskFailed": "Falla na tarefa axendada", + "TaskCleanTranscodeDescription": "Borra os arquivos de transcode anteriores a un día.", + "TaskCleanTranscode": "Limpar Directorio de Transcode", + "UserStoppedPlayingItemWithValues": "{0} rematou de reproducir {1} en {2}", + "UserStartedPlayingItemWithValues": "{0} está reproducindo {1} en {2}", + "TaskDownloadMissingSubtitlesDescription": "Busca en internet por subtítulos que faltan baseado na configuración de metadatos.", + "TaskDownloadMissingSubtitles": "Descargar subtítulos que faltan", + "TaskRefreshChannelsDescription": "Refresca a información do canle de internet.", + "TaskRefreshChannels": "Refrescar Canles", + "TaskUpdatePluginsDescription": "Descarga e instala actualizacións para plugins que están configurados para actualizarse automáticamente.", + "TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa libraría multimedia.", + "TaskRefreshPeople": "Refrescar Persoas", + "TaskCleanLogsDescription": "Borra arquivos de rexistro que son mais antigos que {0} días.", + "TaskRefreshLibraryDescription": "Escanea a tua libraría multimedia buscando novos arquivos e refrescando os metadatos.", + "TaskRefreshLibrary": "Escanear Libraría Multimedia", + "TaskRefreshChapterImagesDescription": "Crea previsualizacións para videos que teñen capítulos.", + "TaskRefreshChapterImages": "Extraer Imaxes dos Capítulos", + "TaskCleanCacheDescription": "Borra ficheiros da caché que xa non son necesarios para o sistema.", + "TaskCleanCache": "Limpa Directorio de Caché", + "TaskCleanActivityLogDescription": "Borra as entradas no rexistro de actividade anteriores á data configurada.", + "TasksApplicationCategory": "Aplicación", + "ValueSpecialEpisodeName": "Especial - {0}", + "ValueHasBeenAddedToLibrary": "{0} foi engadido a túa libraría multimedia", + "TasksLibraryCategory": "Libraría", + "TasksMaintenanceCategory": "Mantemento", + "VersionNumber": "Versión {0}", + "UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}", + "UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}", + "UserOnlineFromDevice": "{0} está en liña desde {1}", + "UserOfflineFromDevice": "{0} desconectouse desde {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index 8780a884b..3364ee333 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -16,7 +16,6 @@ "Folders": "Ordner", "Genres": "Genres", "HeaderAlbumArtists": "Album-Künstler", - "HeaderCameraUploads": "Kamera-Uploads", "HeaderContinueWatching": "weiter schauen", "HeaderFavoriteAlbums": "Lieblingsalben", "HeaderFavoriteArtists": "Lieblings-Künstler", @@ -114,5 +113,10 @@ "TaskUpdatePlugins": "Aktualisiere Erweiterungen", "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schausteller und Regisseure in deiner Bibliothek.", "TaskRefreshPeople": "Aktualisiere Schauspieler", - "TaskCleanLogsDescription": "Löscht Log Dateien die älter als {0} Tage sind." + "TaskCleanLogsDescription": "Löscht Log Dateien die älter als {0} Tage sind.", + "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.", + "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", + "Undefined": "Undefiniert", + "Forced": "Erzwungen", + "Default": "Standard" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index dc3a98154..981e8a06e 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -16,7 +16,6 @@ "Folders": "תיקיות", "Genres": "ז'אנרים", "HeaderAlbumArtists": "אמני האלבום", - "HeaderCameraUploads": "העלאות ממצלמה", "HeaderContinueWatching": "המשך לצפות", "HeaderFavoriteAlbums": "אלבומים מועדפים", "HeaderFavoriteArtists": "אמנים מועדפים", @@ -114,5 +113,10 @@ "TaskRefreshChannels": "רענן ערוץ", "TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.", "TaskCleanTranscode": "נקה תקיית Transcode", - "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי." + "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי.", + "TaskCleanActivityLogDescription": "מחק רשומת פעילות הישנה יותר מהגיל המוגדר.", + "TaskCleanActivityLog": "נקה רשומת פעילות", + "Undefined": "לא מוגדר", + "Forced": "כפוי", + "Default": "ברירת מחדל" } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json new file mode 100644 index 000000000..82dc601bc --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -0,0 +1,64 @@ +{ + "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}", + "NotificationOptionPluginUninstalled": "प्लगइन अनइंस्टाल हो गया", + "NotificationOptionPluginInstalled": "प्लगइन इनस्टॉल हो गया", + "NotificationOptionPluginError": "प्लगइन फ़ैल हो गया", + "NotificationOptionInstallationFailed": "इंस्टालेशन फ़ैल हो गया", + "NotificationOptionAudioPlaybackStopped": "संगीत बंद कर दिया गया", + "NotificationOptionAudioPlayback": "संगीत शुरू कर दिया गया", + "NotificationOptionCameraImageUploaded": "कैमरा फोटो अपलोड किया गया", + "NotificationOptionApplicationUpdateInstalled": "एप्लीकेशन अपडेट इनस्टॉल कर दिया है", + "NotificationOptionApplicationUpdateAvailable": "एप्लीकेशन अपडेट उपलभ्द है", + "NewVersionIsAvailable": "जेलीफिन सर्वर का एक नया वर्जन डाउनलोड के लिए उपलब्ध है।", + "NameSeasonUnknown": "अनजान भाग", + "NameSeasonNumber": "भाग {0}", + "NameInstallFailed": "{0} इनस्टॉल करते समय फेल हो गया है", + "MusicVideos": "संगीत वीडियो", + "Music": "संगीत", + "Movies": "फ़िल्म", + "MixedContent": "मिला-जुला कंटेंट", + "MessageServerConfigurationUpdated": "सर्वर कॉन्फ़िगरेशन अपडेट हो गया है", + "MessageNamedServerConfigurationUpdatedWithValue": "सर्वर कॉन्फ़िगरेशन भाग {0} अपडेट हो गया है", + "MessageApplicationUpdatedTo": "जैलीफिन सर्वर {0} में अपडेट हो गया है", + "MessageApplicationUpdated": "जैलीफिन सर्वर अपडेट हो गया है", + "Latest": "सबसे नया", + "LabelIpAddressValue": "आई पी एड्रेस: {0}", + "ItemRemovedWithName": "{0} लाइब्रेरी में से निकाल दिया है", + "HomeVideos": "होम वीडियोस", + "NotificationOptionVideoPlayback": "वीडियो प्लेबैक शुरू हुआ", + "NotificationOptionUserLockedOut": "उपयोगकर्ता लॉक हो गया", + "NotificationOptionTaskFailed": "निर्धारित कार्य विफलता", + "NotificationOptionServerRestartRequired": "सर्वर पुनरारंभ आवश्यक है", + "NotificationOptionPluginUpdateInstalled": "प्लगइन अद्यतन स्थापित", + "NotificationOptionNewLibraryContent": "नई सामग्री जोड़ी गई", + "LabelRunningTimeValue": "चलने का समय: {0}", + "ItemAddedWithName": "{0} को लाइब्रेरी में जोड़ा गया", + "Inherit": "इनहेरिट" +} diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 97c77017b..9eb80b83b 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -5,18 +5,17 @@ "Artists": "Izvođači", "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija sa kamere je uploadana iz {0}", + "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}", "Channels": "Kanali", "ChapterNameValue": "Poglavlje {0}", "Collections": "Kolekcije", - "DeviceOfflineWithName": "{0} se odspojilo", - "DeviceOnlineWithName": "{0} je spojeno", - "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave za {0}", + "DeviceOfflineWithName": "{0} je prekinuo vezu", + "DeviceOnlineWithName": "{0} je povezan", + "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave od {0}", "Favorites": "Favoriti", "Folders": "Mape", "Genres": "Žanrovi", "HeaderAlbumArtists": "Izvođači na albumu", - "HeaderCameraUploads": "Uvoz sa kamere", "HeaderContinueWatching": "Nastavi gledati", "HeaderFavoriteAlbums": "Omiljeni albumi", "HeaderFavoriteArtists": "Omiljeni izvođači", @@ -24,95 +23,100 @@ "HeaderFavoriteShows": "Omiljene serije", "HeaderFavoriteSongs": "Omiljene pjesme", "HeaderLiveTV": "TV uživo", - "HeaderNextUp": "Sljedeće je", + "HeaderNextUp": "Slijedi", "HeaderRecordingGroups": "Grupa snimka", - "HomeVideos": "Kućni videi", + "HomeVideos": "Kućni video", "Inherit": "Naslijedi", "ItemAddedWithName": "{0} je dodano u biblioteku", - "ItemRemovedWithName": "{0} je uklonjen iz biblioteke", + "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Vrijeme rada: {0}", "Latest": "Najnovije", - "MessageApplicationUpdated": "Jellyfin Server je ažuriran", - "MessageApplicationUpdatedTo": "Jellyfin Server je ažuriran na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Odjeljak postavka servera {0} je ažuriran", - "MessageServerConfigurationUpdated": "Postavke servera su ažurirane", + "MessageApplicationUpdated": "Jellyfin server je ažuriran", + "MessageApplicationUpdatedTo": "Jellyfin server je ažuriran na {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Dio konfiguracije servera {0} je ažuriran", + "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana", "MixedContent": "Miješani sadržaj", "Movies": "Filmovi", "Music": "Glazba", "MusicVideos": "Glazbeni spotovi", "NameInstallFailed": "{0} neuspješnih instalacija", "NameSeasonNumber": "Sezona {0}", - "NameSeasonUnknown": "Nepoznata sezona", + "NameSeasonUnknown": "Sezona nepoznata", "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.", - "NotificationOptionApplicationUpdateAvailable": "Dostupno ažuriranje aplikacije", - "NotificationOptionApplicationUpdateInstalled": "Instalirano ažuriranje aplikacije", - "NotificationOptionAudioPlayback": "Reprodukcija glazbe započeta", - "NotificationOptionAudioPlaybackStopped": "Reprodukcija audiozapisa je zaustavljena", - "NotificationOptionCameraImageUploaded": "Slike kamere preuzete", - "NotificationOptionInstallationFailed": "Instalacija neuspješna", - "NotificationOptionNewLibraryContent": "Novi sadržaj je dodan", - "NotificationOptionPluginError": "Dodatak otkazao", + "NotificationOptionApplicationUpdateAvailable": "Dostupno je ažuriranje aplikacije", + "NotificationOptionApplicationUpdateInstalled": "Instalirano je ažuriranje aplikacije", + "NotificationOptionAudioPlayback": "Reprodukcija glazbe započela", + "NotificationOptionAudioPlaybackStopped": "Reprodukcija glazbe zaustavljena", + "NotificationOptionCameraImageUploaded": "Slika s kamere učitana", + "NotificationOptionInstallationFailed": "Instalacija nije uspjela", + "NotificationOptionNewLibraryContent": "Novi sadržaj dodan", + "NotificationOptionPluginError": "Dodatak zakazao", "NotificationOptionPluginInstalled": "Dodatak instaliran", - "NotificationOptionPluginUninstalled": "Dodatak uklonjen", - "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje za dodatak", - "NotificationOptionServerRestartRequired": "Potrebno ponovo pokretanje servera", - "NotificationOptionTaskFailed": "Zakazan zadatak nije izvršen", + "NotificationOptionPluginUninstalled": "Dodatak deinstaliran", + "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje dodatka", + "NotificationOptionServerRestartRequired": "Ponovno pokrenite server", + "NotificationOptionTaskFailed": "Greška zakazanog zadatka", "NotificationOptionUserLockedOut": "Korisnik zaključan", - "NotificationOptionVideoPlayback": "Reprodukcija videa započeta", - "NotificationOptionVideoPlaybackStopped": "Reprodukcija videozapisa je zaustavljena", - "Photos": "Slike", - "Playlists": "Popis za reprodukciju", + "NotificationOptionVideoPlayback": "Reprodukcija videa započela", + "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa zaustavljena", + "Photos": "Fotografije", + "Playlists": "Popisi za reprodukciju", "Plugin": "Dodatak", "PluginInstalledWithName": "{0} je instalirano", "PluginUninstalledWithName": "{0} je deinstalirano", "PluginUpdatedWithName": "{0} je ažurirano", - "ProviderValue": "Pružitelj: {0}", + "ProviderValue": "Pružatelj: {0}", "ScheduledTaskFailedWithName": "{0} neuspjelo", "ScheduledTaskStartedWithName": "{0} pokrenuto", - "ServerNameNeedsToBeRestarted": "{0} treba biti ponovno pokrenuto", + "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti", "Shows": "Serije", "Songs": "Pjesme", - "StartupEmbyServerIsLoading": "Jellyfin Server se učitava. Pokušajte ponovo kasnije.", + "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.", "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}", - "SubtitleDownloadFailureFromForItem": "Prijevodi nisu uspješno preuzeti {0} od {1}", - "Sync": "Sink.", - "System": "Sistem", + "SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}", + "Sync": "Sinkronizacija", + "System": "Sustav", "TvShows": "Serije", "User": "Korisnik", - "UserCreatedWithName": "Korisnik {0} je stvoren", + "UserCreatedWithName": "Korisnik {0} je kreiran", "UserDeletedWithName": "Korisnik {0} je obrisan", - "UserDownloadingItemWithValues": "{0} se preuzima {1}", + "UserDownloadingItemWithValues": "{0} preuzima {1}", "UserLockedOutWithName": "Korisnik {0} je zaključan", - "UserOfflineFromDevice": "{0} se odspojilo od {1}", - "UserOnlineFromDevice": "{0} je online od {1}", + "UserOfflineFromDevice": "{0} prekinuo vezu od {1}", + "UserOnlineFromDevice": "{0} povezan od {1}", "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}", - "UserPolicyUpdatedWithName": "Pravila za korisnika su ažurirana za {0}", - "UserStartedPlayingItemWithValues": "{0} je pokrenuo {1}", - "UserStoppedPlayingItemWithValues": "{0} je zaustavio {1}", + "UserPolicyUpdatedWithName": "Pravila za korisnika ažurirana su za {0}", + "UserStartedPlayingItemWithValues": "{0} je pokrenuo reprodukciju {1} na {2}", + "UserStoppedPlayingItemWithValues": "{0} je završio reprodukciju {1} na {2}", "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku", - "ValueSpecialEpisodeName": "Specijal - {0}", + "ValueSpecialEpisodeName": "Posebno - {0}", "VersionNumber": "Verzija {0}", - "TaskRefreshLibraryDescription": "Skenira vašu medijsku knjižnicu sa novim datotekama i osvježuje metapodatke.", - "TaskRefreshLibrary": "Skeniraj medijsku knjižnicu", - "TaskRefreshChapterImagesDescription": "Stvara sličice za videozapise koji imaju poglavlja.", - "TaskRefreshChapterImages": "Raspakiraj slike poglavlja", - "TaskCleanCacheDescription": "Briše priručne datoteke nepotrebne za sistem.", - "TaskCleanCache": "Očisti priručnu memoriju", + "TaskRefreshLibraryDescription": "Skenira medijsku biblioteku radi novih datoteka i osvježava metapodatke.", + "TaskRefreshLibrary": "Skeniraj medijsku biblioteku", + "TaskRefreshChapterImagesDescription": "Kreira sličice za videozapise koji imaju poglavlja.", + "TaskRefreshChapterImages": "Izdvoji slike poglavlja", + "TaskCleanCacheDescription": "Briše nepotrebne datoteke iz predmemorije.", + "TaskCleanCache": "Očisti mapu predmemorije", "TasksApplicationCategory": "Aplikacija", "TasksMaintenanceCategory": "Održavanje", - "TaskDownloadMissingSubtitlesDescription": "Pretraživanje interneta za prijevodima koji nedostaju bazirano na konfiguraciji meta podataka.", - "TaskDownloadMissingSubtitles": "Preuzimanje prijevoda koji nedostaju", - "TaskRefreshChannelsDescription": "Osvježava informacije o internet kanalima.", + "TaskDownloadMissingSubtitlesDescription": "Pretraži Internet za prijevodima koji nedostaju prema konfiguraciji metapodataka.", + "TaskDownloadMissingSubtitles": "Preuzmi prijevod koji nedostaje", + "TaskRefreshChannelsDescription": "Osvježava informacije Internet kanala.", "TaskRefreshChannels": "Osvježi kanale", - "TaskCleanTranscodeDescription": "Briše transkodirane fajlove starije od jednog dana.", - "TaskCleanTranscode": "Očisti direktorij za transkodiranje", - "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su podešeni da se ažuriraju automatski.", + "TaskCleanTranscodeDescription": "Briše transkodirane datoteke starije od jednog dana.", + "TaskCleanTranscode": "Očisti mapu transkodiranja", + "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su konfigurirani da se ažuriraju automatski.", "TaskUpdatePlugins": "Ažuriraj dodatke", - "TaskRefreshPeopleDescription": "Ažurira meta podatke za glumce i redatelje u vašoj medijskoj biblioteci.", - "TaskRefreshPeople": "Osvježi ljude", - "TaskCleanLogsDescription": "Briši logove koji su stariji od {0} dana.", - "TaskCleanLogs": "Očisti direktorij sa logovima", + "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i redatelje u medijskoj biblioteci.", + "TaskRefreshPeople": "Osvježi osobe", + "TaskCleanLogsDescription": "Briše zapise dnevnika koji su stariji od {0} dana.", + "TaskCleanLogs": "Očisti mapu dnevnika zapisa", "TasksChannelsCategory": "Internet kanali", - "TasksLibraryCategory": "Biblioteka" + "TasksLibraryCategory": "Biblioteka", + "TaskCleanActivityLogDescription": "Briše zapise dnevnika aktivnosti starije od navedenog vremena.", + "TaskCleanActivityLog": "Očisti dnevnik aktivnosti", + "Undefined": "Nedefinirano", + "Forced": "Forsirani", + "Default": "Zadano" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index c5c3844e3..85ab1511a 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -15,8 +15,7 @@ "Favorites": "Kedvencek", "Folders": "Könyvtárak", "Genres": "Műfajok", - "HeaderAlbumArtists": "Album előadók", - "HeaderCameraUploads": "Kamera feltöltések", + "HeaderAlbumArtists": "Előadó albumai", "HeaderContinueWatching": "Megtekintés folytatása", "HeaderFavoriteAlbums": "Kedvenc albumok", "HeaderFavoriteArtists": "Kedvenc előadók", @@ -40,7 +39,7 @@ "MixedContent": "Vegyes tartalom", "Movies": "Filmek", "Music": "Zene", - "MusicVideos": "Zenei videók", + "MusicVideos": "Zenei videóklippek", "NameInstallFailed": "{0} sikertelen telepítés", "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", @@ -50,7 +49,7 @@ "NotificationOptionAudioPlayback": "Audió lejátszás elkezdve", "NotificationOptionAudioPlaybackStopped": "Audió lejátszás leállítva", "NotificationOptionCameraImageUploaded": "Kamera kép feltöltve", - "NotificationOptionInstallationFailed": "Telepítési hiba", + "NotificationOptionInstallationFailed": "Telepítés sikertelen", "NotificationOptionNewLibraryContent": "Új tartalom hozzáadva", "NotificationOptionPluginError": "Bővítmény hiba", "NotificationOptionPluginInstalled": "Bővítmény telepítve", @@ -75,7 +74,7 @@ "Songs": "Dalok", "StartupEmbyServerIsLoading": "A Jellyfin Szerver betöltődik. Kérlek, próbáld újra hamarosan.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0} ehhez: {1}", + "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0} ehhez: {1}", "Sync": "Szinkronizál", "System": "Rendszer", "TvShows": "TV műsorok", @@ -83,12 +82,12 @@ "UserCreatedWithName": "{0} felhasználó létrehozva", "UserDeletedWithName": "{0} felhasználó törölve", "UserDownloadingItemWithValues": "{0} letölti {1}", - "UserLockedOutWithName": "{0} felhasználó zárolva van", - "UserOfflineFromDevice": "{0} kijelentkezett innen: {1}", + "UserLockedOutWithName": "{0} felhasználó zárolva van", + "UserOfflineFromDevice": "{0} kijelentkezett innen: {1}", "UserOnlineFromDevice": "{0} online innen: {1}", "UserPasswordChangedWithName": "Jelszó megváltozott a következő felhasználó számára: {0}", "UserPolicyUpdatedWithName": "A felhasználói házirend frissítve lett neki: {0}", - "UserStartedPlayingItemWithValues": "{0} elkezdte játszani a következőt: {1} itt: {2}", + "UserStartedPlayingItemWithValues": "{0} elkezdte játszani a következőt: {1} itt: {2}", "UserStoppedPlayingItemWithValues": "{0} befejezte {1} lejátászását itt: {2}", "ValueHasBeenAddedToLibrary": "{0} hozzáadva a médiatárhoz", "ValueSpecialEpisodeName": "Special - {0}", @@ -114,5 +113,12 @@ "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése", "TaskRefreshChannelsDescription": "Frissíti az internetes csatornák adatait.", "TaskRefreshChannels": "Csatornák frissítése", - "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat." + "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.", + "TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.", + "TaskCleanActivityLog": "Tevékenységnapló törlése", + "Undefined": "Meghatározatlan", + "Forced": "Kényszerített", + "Default": "Alapértelmezett", + "TaskOptimizeDatabaseDescription": "Tömöríti az adatbázist és csonkolja a szabad helyet. A feladat futtatása a könyvtár beolvasása után, vagy egyéb, adatbázis-módosítást igénylő változtatások végrehajtása javíthatja a teljesítményt.", + "TaskOptimizeDatabase": "Adatbázis optimalizálása" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index b0dfc312e..ba3513870 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -1,7 +1,7 @@ { "Albums": "Album", "AuthenticationSucceededWithUserName": "{0} berhasil diautentikasi", - "AppDeviceValues": "Aplikasi : {0}, Alat : {1}", + "AppDeviceValues": "Aplikasi : {0}, Perangkat : {1}", "LabelRunningTimeValue": "Waktu berjalan: {0}", "MessageApplicationUpdatedTo": "Jellyfin Server sudah diperbarui ke {0}", "MessageApplicationUpdated": "Jellyfin Server sudah diperbarui", @@ -19,8 +19,7 @@ "HeaderFavoriteEpisodes": "Episode Favorit", "HeaderFavoriteArtists": "Artis Favorit", "HeaderFavoriteAlbums": "Album Favorit", - "HeaderContinueWatching": "Lanjutkan Menonton", - "HeaderCameraUploads": "Unggahan Kamera", + "HeaderContinueWatching": "Lanjut Menonton", "HeaderAlbumArtists": "Album Artis", "Genres": "Aliran", "Folders": "Folder", @@ -113,5 +112,10 @@ "TaskRefreshPeople": "Muat ulang Orang", "TaskCleanLogsDescription": "Menghapus file log yang lebih dari {0} hari.", "TaskCleanLogs": "Bersihkan Log Direktori", - "TaskRefreshLibrary": "Pindai Pustaka Media" + "TaskRefreshLibrary": "Pindai Pustaka Media", + "TaskCleanActivityLogDescription": "Menghapus log aktivitas yang lebih tua dari umur yang dikonfigurasi.", + "TaskCleanActivityLog": "Bersihkan Log Aktivitas", + "Undefined": "Tidak terdefinisi", + "Forced": "Dipaksa", + "Default": "Bawaan" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index 0f0f9130b..b262a8b42 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -13,7 +13,6 @@ "HeaderFavoriteArtists": "Uppáhalds Listamenn", "HeaderFavoriteAlbums": "Uppáhalds Plötur", "HeaderContinueWatching": "Halda áfram að horfa", - "HeaderCameraUploads": "Myndavéla upphal", "HeaderAlbumArtists": "Höfundur plötu", "Genres": "Tegundir", "Folders": "Möppur", @@ -26,7 +25,7 @@ "Channels": "Stöðvar", "CameraImageUploadedFrom": "Ný ljósmynd frá myndavél hefur verið hlaðið upp frá {0}", "Books": "Bækur", - "AuthenticationSucceededWithUserName": "{0} náði að auðkennast", + "AuthenticationSucceededWithUserName": "{0} auðkenning tókst", "Artists": "Listamaður", "Application": "Forrit", "AppDeviceValues": "Snjallforrit: {0}, Tæki: {1}", @@ -107,5 +106,6 @@ "TasksChannelsCategory": "Netrásir", "TasksApplicationCategory": "Forrit", "TasksLibraryCategory": "Miðlasafn", - "TasksMaintenanceCategory": "Viðhald" + "TasksMaintenanceCategory": "Viðhald", + "Default": "Sjálfgefið" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index bf1a0ef13..5e28cf09f 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -15,8 +15,7 @@ "Favorites": "Preferiti", "Folders": "Cartelle", "Genres": "Generi", - "HeaderAlbumArtists": "Artisti degli Album", - "HeaderCameraUploads": "Caricamenti Fotocamera", + "HeaderAlbumArtists": "Artisti dell'Album", "HeaderContinueWatching": "Continua a guardare", "HeaderFavoriteAlbums": "Album Preferiti", "HeaderFavoriteArtists": "Artisti Preferiti", @@ -26,7 +25,7 @@ "HeaderLiveTV": "Diretta TV", "HeaderNextUp": "Prossimo", "HeaderRecordingGroups": "Gruppi di Registrazione", - "HomeVideos": "Video personali", + "HomeVideos": "Video Personali", "Inherit": "Eredita", "ItemAddedWithName": "{0} è stato aggiunto alla libreria", "ItemRemovedWithName": "{0} è stato rimosso dalla libreria", @@ -40,7 +39,7 @@ "MixedContent": "Contenuto misto", "Movies": "Film", "Music": "Musica", - "MusicVideos": "Video musicali", + "MusicVideos": "Video Musicali", "NameInstallFailed": "{0} installazione fallita", "NameSeasonNumber": "Stagione {0}", "NameSeasonUnknown": "Stagione sconosciuta", @@ -63,7 +62,7 @@ "NotificationOptionVideoPlaybackStopped": "La riproduzione video è stata interrotta", "Photos": "Foto", "Playlists": "Playlist", - "Plugin": "Plug-in", + "Plugin": "Plugin", "PluginInstalledWithName": "{0} è stato Installato", "PluginUninstalledWithName": "{0} è stato disinstallato", "PluginUpdatedWithName": "{0} è stato aggiornato", @@ -71,7 +70,7 @@ "ScheduledTaskFailedWithName": "{0} fallito", "ScheduledTaskStartedWithName": "{0} avviati", "ServerNameNeedsToBeRestarted": "{0} deve essere riavviato", - "Shows": "Programmi", + "Shows": "Serie TV", "Songs": "Canzoni", "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.", "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}", @@ -88,7 +87,7 @@ "UserOnlineFromDevice": "{0} è online su {1}", "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}", "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}", - "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}", + "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di \"{1}\" su {2}", "UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1} su {2}", "ValueHasBeenAddedToLibrary": "{0} è stato aggiunto alla tua libreria multimediale", "ValueSpecialEpisodeName": "Speciale - {0}", @@ -114,5 +113,12 @@ "TasksChannelsCategory": "Canali su Internet", "TasksApplicationCategory": "Applicazione", "TasksLibraryCategory": "Libreria", - "TasksMaintenanceCategory": "Manutenzione" + "TasksMaintenanceCategory": "Manutenzione", + "TaskCleanActivityLog": "Attività di Registro Completate", + "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.", + "Undefined": "Non Definito", + "Forced": "Forzato", + "Default": "Predefinito", + "TaskOptimizeDatabaseDescription": "Compatta Database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altri cambiamenti inerenti il database potrebbe aumentarne la performance.", + "TaskOptimizeDatabase": "Ottimizza Database" } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index a4d9f9ef6..0d90ad31c 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}", @@ -16,7 +16,6 @@ "Folders": "フォルダー", "Genres": "ジャンル", "HeaderAlbumArtists": "アルバムアーティスト", - "HeaderCameraUploads": "カメラアップロード", "HeaderContinueWatching": "視聴を続ける", "HeaderFavoriteAlbums": "お気に入りのアルバム", "HeaderFavoriteArtists": "お気に入りのアーティスト", @@ -97,7 +96,7 @@ "TaskRefreshLibraryDescription": "メディアライブラリをスキャンして新しいファイルを探し、メタデータをリフレッシュします。", "TaskRefreshLibrary": "メディアライブラリのスキャン", "TaskCleanCacheDescription": "不要なキャッシュを消去します。", - "TaskCleanCache": "キャッシュの掃除", + "TaskCleanCache": "キャッシュを消去", "TasksChannelsCategory": "ネットチャンネル", "TasksApplicationCategory": "アプリケーション", "TasksLibraryCategory": "ライブラリ", @@ -113,5 +112,12 @@ "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索します。", "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。", "TaskRefreshChapterImages": "チャプター画像を抽出する", - "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする" + "TaskDownloadMissingSubtitles": "不足している字幕をダウンロードする", + "TaskCleanActivityLogDescription": "設定された期間よりも古いアクティビティの履歴を削除します。", + "TaskCleanActivityLog": "アクティビティの履歴を消去", + "Undefined": "未定義", + "Forced": "強制", + "Default": "デフォルト", + "TaskOptimizeDatabaseDescription": "データベースをコンパクトにして、空き領域を切り詰めます。メディアライブラリのスキャン後でこのタスクを実行するとパフォーマンスが向上する可能性があります。", + "TaskOptimizeDatabase": "データベースの最適化" } diff --git a/Emby.Server.Implementations/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json index 5618ff4a8..d28564a7c 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -1,96 +1,124 @@ { - "Albums": "Álbomdar", - "AppDeviceValues": "Qoldanba: {0}, Qurylǵy: {1}", + "Albums": "Älbomdar", + "AppDeviceValues": "Qoldanba: {0}, Qūrylğy: {1}", "Application": "Qoldanba", - "Artists": "Oryndaýshylar", - "AuthenticationSucceededWithUserName": "{0} túpnusqalyq rastalýy sátti aıaqtaldy", - "Books": "Kitaptar", - "CameraImageUploadedFrom": "{0} kamerasynan jańa sýret júktep salyndy", + "Artists": "Oryndauşylar", + "AuthenticationSucceededWithUserName": "{0} tüpnūsqalyq rastaluy sättı aiaqtaldy", + "Books": "Kıtaptar", + "CameraImageUploadedFrom": "{0} kamerasynan jaña suret jüktep salyndy", "Channels": "Arnalar", "ChapterNameValue": "{0}-sahna", - "Collections": "Jıyntyqtar", - "DeviceOfflineWithName": "{0} ajyratylǵan", - "DeviceOnlineWithName": "{0} qosylǵan", - "FailedLoginAttemptWithUserName": "{0} tarapynan kirý áreketi sátsiz aıaqtaldy", - "Favorites": "Tańdaýlylar", + "Collections": "Jiyntyqtar", + "DeviceOfflineWithName": "{0} ajyratylğan", + "DeviceOnlineWithName": "{0} qosylğan", + "FailedLoginAttemptWithUserName": "{0} tarapynan kıru äreketı sätsız aiaqtaldy", + "Favorites": "Tañdaulylar", "Folders": "Qaltalar", "Genres": "Janrlar", - "HeaderAlbumArtists": "Álbom oryndaýshylary", - "HeaderCameraUploads": "Kameradan júktelgender", - "HeaderContinueWatching": "Qaraýdy jalǵastyrý", - "HeaderFavoriteAlbums": "Tańdaýly álbomdar", - "HeaderFavoriteArtists": "Tańdaýly oryndaýshylar", - "HeaderFavoriteEpisodes": "Tańdaýly bólimder", - "HeaderFavoriteShows": "Tańdaýly kórsetimder", - "HeaderFavoriteSongs": "Tańdaýly áýender", - "HeaderLiveTV": "Efır", - "HeaderNextUp": "Kezekti", + "HeaderAlbumArtists": "Oryndauşynyñ älbomy", + "HeaderContinueWatching": "Qaraudy jalğastyru", + "HeaderFavoriteAlbums": "Tañdauly älbomdar", + "HeaderFavoriteArtists": "Tañdauly oryndauşylar", + "HeaderFavoriteEpisodes": "Tañdauly telebölımder", + "HeaderFavoriteShows": "Tañdauly körsetımder", + "HeaderFavoriteSongs": "Tañdauly äuender", + "HeaderLiveTV": "Efir", + "HeaderNextUp": "Kezektı", "HeaderRecordingGroups": "Jazba toptary", - "HomeVideos": "Úılik beıneler", - "Inherit": "Muraǵa ıelený", - "ItemAddedWithName": "{0} tasyǵyshhanaǵa ústeldi", - "ItemRemovedWithName": "{0} tasyǵyshhanadan alastaldy", - "LabelIpAddressValue": "IP-mekenjaıy: {0}", - "LabelRunningTimeValue": "Oınatý ýaqyty: {0}", - "Latest": "Eń keıingi", - "MessageApplicationUpdated": "Jellyfin Serveri jańartyldy", - "MessageApplicationUpdatedTo": "Jellyfin Serveri {0} nusqasyna jańartyldy", - "MessageNamedServerConfigurationUpdatedWithValue": "Server konfıgýrasýasynyń {0} bólimi jańartyldy", - "MessageServerConfigurationUpdated": "Server konfıgýrasıasy jańartyldy", - "MixedContent": "Aralas mazmun", - "Movies": "Fılmder", - "Music": "Mýzyka", - "MusicVideos": "Mýzykalyq beıneler", - "NameInstallFailed": "{0} ornatylýy sátsiz", - "NameSeasonNumber": "{0}-maýsym", - "NameSeasonUnknown": "Belgisiz maýsym", - "NewVersionIsAvailable": "Jańa Jellyfin Server nusqasy júktep alýǵa qoljetimdi.", - "NotificationOptionApplicationUpdateAvailable": "Qoldanba jańartýy qoljetimdi", - "NotificationOptionApplicationUpdateInstalled": "Qoldanba jańartýy ornatyldy", - "NotificationOptionAudioPlayback": "Dybys oınatýy bastaldy", - "NotificationOptionAudioPlaybackStopped": "Dybys oınatýy toqtatyldy", - "NotificationOptionCameraImageUploaded": "Kameradan fotosýret júktep salynǵan", - "NotificationOptionInstallationFailed": "Ornatý sátsizdigi", - "NotificationOptionNewLibraryContent": "Jańa mazmun ústelgen", - "NotificationOptionPluginError": "Plagın sátsizdigi", - "NotificationOptionPluginInstalled": "Plagın ornatyldy", - "NotificationOptionPluginUninstalled": "Plagın ornatýy boldyrylmady", - "NotificationOptionPluginUpdateInstalled": "Plagın jańartýy ornatyldy", - "NotificationOptionServerRestartRequired": "Serverdi qaıta iske qosý qajet", - "NotificationOptionTaskFailed": "Josparlaǵan tapsyrma sátsizdigi", - "NotificationOptionUserLockedOut": "Paıdalanýshy qursaýly", - "NotificationOptionVideoPlayback": "Beıne oınatýy bastaldy", - "NotificationOptionVideoPlaybackStopped": "Beıne oınatýy toqtatyldy", - "Photos": "Fotosýretter", - "Playlists": "Oınatý tizimderi", - "Plugin": "Plagın", + "HomeVideos": "Üilık beineler", + "Inherit": "İelenu", + "ItemAddedWithName": "{0} tasyğyşhanağa üstelindı", + "ItemRemovedWithName": "{0} tasyğyşhanadan alastaldy", + "LabelIpAddressValue": "IP-mekenjaiy: {0}", + "LabelRunningTimeValue": "Oinatu uaqyty: {0}", + "Latest": "Eñ keiıngı", + "MessageApplicationUpdated": "Jellyfin Serverı jañartyldy", + "MessageApplicationUpdatedTo": "Jellyfin Serverı {0} nūsqasyna jañartyldy", + "MessageNamedServerConfigurationUpdatedWithValue": "Server teñşelımderınıñ {0} bölımı jañartyldy", + "MessageServerConfigurationUpdated": "Server teñşelımderı jañartyldy", + "MixedContent": "Aralas mazmūn", + "Movies": "Filmder", + "Music": "Muzyka", + "MusicVideos": "Muzykalyq beineler", + "NameInstallFailed": "{0} ornatyluy sätsız", + "NameSeasonNumber": "{0}-mausym", + "NameSeasonUnknown": "Belgısız mausym", + "NewVersionIsAvailable": "Jaña Jellyfin Server nūsqasy jüktep aluğa qoljetımdı.", + "NotificationOptionApplicationUpdateAvailable": "Qoldanba jañartuy qoljetımdı", + "NotificationOptionApplicationUpdateInstalled": "Qoldanba jañartuy ornatyldy", + "NotificationOptionAudioPlayback": "Dybys oinatuy bastaldy", + "NotificationOptionAudioPlaybackStopped": "Dybys oinatuy toqtatyldy", + "NotificationOptionCameraImageUploaded": "Kameradan fotosuret jüktep salynğan", + "NotificationOptionInstallationFailed": "Ornatu sätsızdıgı", + "NotificationOptionNewLibraryContent": "Jaña mazmūn üstelıngen", + "NotificationOptionPluginError": "Plagin sätsızdıgı", + "NotificationOptionPluginInstalled": "Plagin ornatyldy", + "NotificationOptionPluginUninstalled": "Plagin ornatuy boldyrylmady", + "NotificationOptionPluginUpdateInstalled": "Plagin jañartuy ornatyldy", + "NotificationOptionServerRestartRequired": "Serverdı qaita ıske qosu qajet", + "NotificationOptionTaskFailed": "Josparlağan tapsyrma sätsızdıgı", + "NotificationOptionUserLockedOut": "Paidalanuşy qūrsauly", + "NotificationOptionVideoPlayback": "Beine oinatuy bastaldy", + "NotificationOptionVideoPlaybackStopped": "Beine oinatuy toqtatyldy", + "Photos": "Fotosuretter", + "Playlists": "Oinatu tızımderı", + "Plugin": "Plagin", "PluginInstalledWithName": "{0} ornatyldy", - "PluginUninstalledWithName": "{0} joıyldy", - "PluginUpdatedWithName": "{0} jańartyldy", - "ProviderValue": "Jetkizýshi: {0}", - "ScheduledTaskFailedWithName": "{0} sátsiz", - "ScheduledTaskStartedWithName": "{0} iske qosyldy", - "ServerNameNeedsToBeRestarted": "{0} qaıta iske qosý qajet", - "Shows": "Kórsetimder", - "Songs": "Áýender", - "StartupEmbyServerIsLoading": "Jellyfin Server júktelýde. Áreketti kóp uzamaı qaıtalańyz.", + "PluginUninstalledWithName": "{0} joiyldy", + "PluginUpdatedWithName": "{0} jañartyldy", + "ProviderValue": "Jetkızuşı: {0}", + "ScheduledTaskFailedWithName": "{0} sätsız", + "ScheduledTaskStartedWithName": "{0} ıske qosyldy", + "ServerNameNeedsToBeRestarted": "{0} qaita ıske qosu qajet", + "Shows": "Körsetımder", + "Songs": "Äuender", + "StartupEmbyServerIsLoading": "Jellyfin Server jüktelude. Ärekettı köp ūzamai qaitalañyz.", "SubtitleDownloadFailureForItem": "Субтитрлер {0} үшін жүктеліп алынуы сәтсіз", - "SubtitleDownloadFailureFromForItem": "{1} úshin sýbtıtrlerdi {0} kózinen júktep alý sátsiz", - "Sync": "Úndestirý", - "System": "Júıe", - "TvShows": "TD-kórsetimder", - "User": "Paıdalanýshy", - "UserCreatedWithName": "Paıdalanýshy {0} jasalǵan", - "UserDeletedWithName": "Paıdalanýshy {0} joıylǵan", - "UserDownloadingItemWithValues": "{0} mynany júktep alýda: {1}", - "UserLockedOutWithName": "Paıdalanýshy {0} qursaýly", - "UserOfflineFromDevice": "{0} - {1} tarapynan ajyratylǵan", - "UserOnlineFromDevice": "{0} - {1} arqyly qosylǵan", - "UserPasswordChangedWithName": "Paıdalanýshy {0} úshin paról ózgertildi", - "UserPolicyUpdatedWithName": "Paıdalanýshy {0} úshin saıasattary jańartyldy", - "UserStartedPlayingItemWithValues": "{0} - {1} oınatýyn {2} bastady", - "UserStoppedPlayingItemWithValues": "{0} - {1} oınatýyn {2} toqtatty", - "ValueHasBeenAddedToLibrary": "{0} (tasyǵyshhanaǵa ústelindi)", - "ValueSpecialEpisodeName": "Arnaıy - {0}", - "VersionNumber": "Nusqasy {0}" + "SubtitleDownloadFailureFromForItem": "{1} üşın subtitrlerdı {0} közınen jüktep alu sätsız", + "Sync": "Ündestıru", + "System": "Jüie", + "TvShows": "TD-körsetımder", + "User": "Paidalanuşy", + "UserCreatedWithName": "Paidalanuşy {0} jasalğan", + "UserDeletedWithName": "Paidalanuşy {0} joiylğan", + "UserDownloadingItemWithValues": "{0} — {1} jüktep aluda", + "UserLockedOutWithName": "Paidalanuşy {0} qūrsaulanğan", + "UserOfflineFromDevice": "{0} — {1} tarapynan ajyratyldy", + "UserOnlineFromDevice": "{0} — {1} tarapynan qosyldy", + "UserPasswordChangedWithName": "Paidalanuşy {0} üşın paröl özgertıldı", + "UserPolicyUpdatedWithName": "Paidalanuşy {0} üşın saiasattary jañartyldy", + "UserStartedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuda", + "UserStoppedPlayingItemWithValues": "{0} — {2} tarapynan {1} oinatuyn toqtatty", + "ValueHasBeenAddedToLibrary": "{0} tasyğyşhanağa üstelındı", + "ValueSpecialEpisodeName": "Arnaiy - {0}", + "VersionNumber": "Nūsqasy {0}", + "Default": "Ädepkı", + "TaskDownloadMissingSubtitles": "Joq subtitrlerdı jüktep alu", + "TaskRefreshChannels": "Arnalardy jañğyrtu", + "TaskCleanTranscode": "Qaita kodtau katalogyn tazalau", + "TaskUpdatePlugins": "Plaginderdı jañartu", + "TaskRefreshPeople": "Adamdardy jañğyrtu", + "TaskCleanLogs": "Jūrnal katalogyn tazalau", + "TaskRefreshLibrary": "Tasyğyşhanany skanerleu", + "TaskRefreshChapterImages": "Sahna suretterın şyğaryp alu", + "TaskCleanCache": "Keş katalogyn tazalau", + "TaskCleanActivityLog": "Äreket jūrnalyn tazalau", + "TasksChannelsCategory": "Internet-arnalar", + "TasksApplicationCategory": "Qoldanba", + "TasksLibraryCategory": "Tasyğyşhana", + "TasksMaintenanceCategory": "Qyzmet körsetu", + "Undefined": "Anyqtalmağan", + "Forced": "Mäjbürlı", + "TaskDownloadMissingSubtitlesDescription": "Metaderekter teñşelımderı negızınde joq subtitrlerdı İnternetten ızdeidı.", + "TaskRefreshChannelsDescription": "Internet-arnalar mälımetterın jañğyrtady.", + "TaskCleanTranscodeDescription": "Bіr künnen asqan qaita kodtau faildaryn joiady.", + "TaskUpdatePluginsDescription": "Avtomatty türde jañartuğa teñşelgen plaginder üşın jañartulardy jüktep alady jäne ornatady.", + "TaskRefreshPeopleDescription": "Tasyğyşhanadağy aktörler men rejisörler metaderekterın jañartady.", + "TaskCleanLogsDescription": "{0} künnen asqan jūrnal faildaryn joiady.", + "TaskRefreshLibraryDescription": "Tasyğyşhanadağy jaña faildardy skanerleidі jäne metaderekterdı jañğyrtady.", + "TaskRefreshChapterImagesDescription": "Sahnalary bar beineler üşın nobailar jasaidy.", + "TaskCleanCacheDescription": "Jüiede qajet emes keştelgen faildardy joiady.", + "TaskCleanActivityLogDescription": "Äreket jūrnalyndağy teñşelgen jasynan asqan jazbalary joiady.", + "TaskOptimizeDatabaseDescription": "Derekqordy qysyp, bos oryndy qysqartady. Būl tapsyrmany tasyğyşhanany skanerlegennen keiın nemese derekqorğa meñzeitın basqa özgertuler ıstelgennen keiın oryndau önımdılıktı damytuy mümkın.", + "TaskOptimizeDatabase": "Derekqordy oñtailandyru" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 9e3ecd5a8..409b4d26b 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -16,7 +16,6 @@ "Folders": "폴더", "Genres": "장르", "HeaderAlbumArtists": "앨범 아티스트", - "HeaderCameraUploads": "카메라 업로드", "HeaderContinueWatching": "계속 시청하기", "HeaderFavoriteAlbums": "즐겨찾는 앨범", "HeaderFavoriteArtists": "즐겨찾는 아티스트", @@ -28,7 +27,7 @@ "HeaderRecordingGroups": "녹화 그룹", "HomeVideos": "홈 비디오", "Inherit": "상속", - "ItemAddedWithName": "{0}가 라이브러리에 추가됨", + "ItemAddedWithName": "{0}가 라이브러리에 추가되었습니다", "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨", "LabelIpAddressValue": "IP 주소: {0}", "LabelRunningTimeValue": "상영 시간: {0}", @@ -84,8 +83,8 @@ "UserDeletedWithName": "사용자 {0} 삭제됨", "UserDownloadingItemWithValues": "{0}이(가) {1}을 다운로드 중입니다", "UserLockedOutWithName": "유저 {0} 은(는) 잠금처리 되었습니다", - "UserOfflineFromDevice": "{1}로부터 {0}의 연결이 끊겼습니다", - "UserOnlineFromDevice": "{0}은 {1}에서 온라인 상태입니다", + "UserOfflineFromDevice": "{1}에서 {0}의 연결이 끊킴", + "UserOnlineFromDevice": "{0}이 {1}으로 접속", "UserPasswordChangedWithName": "사용자 {0}의 비밀번호가 변경되었습니다", "UserPolicyUpdatedWithName": "{0}의 사용자 정책이 업데이트되었습니다", "UserStartedPlayingItemWithValues": "{2}에서 {0}이 {1} 재생 중", @@ -114,5 +113,12 @@ "TaskCleanCacheDescription": "시스템에서 더 이상 필요하지 않은 캐시 파일을 삭제합니다.", "TaskCleanCache": "캐시 폴더 청소", "TasksChannelsCategory": "인터넷 채널", - "TasksLibraryCategory": "라이브러리" + "TasksLibraryCategory": "라이브러리", + "TaskCleanActivityLogDescription": "구성된 기간보다 오래된 활동내역 삭제.", + "TaskCleanActivityLog": "활동내역청소", + "Undefined": "일치하지 않음", + "Forced": "강제하기", + "Default": "기본 설정", + "TaskOptimizeDatabaseDescription": "데이터베이스를 압축하고 사용 가능한 공간을 늘립니다. 라이브러리를 검색한 후 이 작업을 실행하거나 데이터베이스 수정같은 비슷한 작업을 수행하면 성능이 향상될 수 있습니다.", + "TaskOptimizeDatabase": "데이터베이스 최적화" } diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index 35053766b..f3a131d40 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -16,7 +16,6 @@ "Folders": "Katalogai", "Genres": "Žanrai", "HeaderAlbumArtists": "Albumo atlikėjai", - "HeaderCameraUploads": "Kameros", "HeaderContinueWatching": "Žiūrėti toliau", "HeaderFavoriteAlbums": "Mėgstami Albumai", "HeaderFavoriteArtists": "Mėgstami Atlikėjai", @@ -114,5 +113,10 @@ "TasksChannelsCategory": "Internetiniai Kanalai", "TasksApplicationCategory": "Programa", "TasksLibraryCategory": "Mediateka", - "TasksMaintenanceCategory": "Priežiūra" + "TasksMaintenanceCategory": "Priežiūra", + "TaskCleanActivityLog": "Išvalyti veiklos žurnalą", + "Undefined": "Neapibrėžtas", + "Forced": "Priverstas", + "Default": "Numatytas", + "TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius." } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index dbcf17287..443a74a10 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}", @@ -72,7 +72,6 @@ "ItemAddedWithName": "{0} tika pievienots bibliotēkai", "HeaderLiveTV": "Tiešraides TV", "HeaderContinueWatching": "Turpināt Skatīšanos", - "HeaderCameraUploads": "Kameras augšupielādes", "HeaderAlbumArtists": "Albumu Izpildītāji", "Genres": "Žanri", "Folders": "Mapes", @@ -96,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", @@ -104,14 +103,21 @@ "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", + "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Uzdevum palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.", + "TaskOptimizeDatabase": "Optimizēt datubāzi" } diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index bbdf99aba..b780ef498 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -51,7 +51,6 @@ "HeaderFavoriteArtists": "Омилени Изведувачи", "HeaderFavoriteAlbums": "Омилени Албуми", "HeaderContinueWatching": "Продолжи со гледање", - "HeaderCameraUploads": "Поставувања од камера", "HeaderAlbumArtists": "Изведувачи од Албуми", "Genres": "Жанрови", "Folders": "Папки", diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json new file mode 100644 index 000000000..09ef34913 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -0,0 +1,123 @@ +{ + "AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}", + "Application": "അപ്ലിക്കേഷൻ", + "AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു", + "CameraImageUploadedFrom": "Camera 0 from എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്ലോഡുചെയ്തു", + "ChapterNameValue": "അധ്യായം {0}", + "DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു", + "DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു", + "FailedLoginAttemptWithUserName": "Log 0 from എന്നതിൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", + "Forced": "നിർബന്ധിച്ചു", + "HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ", + "HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ", + "HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ", + "HeaderFavoriteShows": "പ്രിയപ്പെട്ട ഷോകൾ", + "HeaderFavoriteSongs": "പ്രിയപ്പെട്ട ഗാനങ്ങൾ", + "HeaderLiveTV": "തത്സമയ ടിവി", + "HeaderNextUp": "അടുത്തത്", + "HeaderRecordingGroups": "ഗ്രൂപ്പുകൾ റെക്കോർഡുചെയ്യുന്നു", + "HomeVideos": "ഹോം വീഡിയോകൾ", + "Inherit": "അനന്തരാവകാശം", + "ItemAddedWithName": "{0} ലൈബ്രറിയിൽ ചേർത്തു", + "ItemRemovedWithName": "{0} ലൈബ്രറിയിൽ നിന്ന് നീക്കംചെയ്തു", + "LabelIpAddressValue": "IP വിലാസം: {0}", + "LabelRunningTimeValue": "പ്രവർത്തന സമയം: {0}", + "Latest": "ഏറ്റവും പുതിയ", + "MessageApplicationUpdated": "ജെല്ലിഫിൻ സെർവർ അപ്ഡേറ്റുചെയ്തു", + "MessageApplicationUpdatedTo": "ജെല്ലിഫിൻ സെർവർ {0 to ലേക്ക് അപ്ഡേറ്റുചെയ്തു", + "MessageNamedServerConfigurationUpdatedWithValue": "സെർവർ കോൺഫിഗറേഷൻ വിഭാഗം {0 അപ്ഡേറ്റുചെയ്തു", + "MessageServerConfigurationUpdated": "സെർവർ കോൺഫിഗറേഷൻ അപ്ഡേറ്റുചെയ്തു", + "MixedContent": "മിശ്രിത ഉള്ളടക്കം", + "Music": "സംഗീതം", + "MusicVideos": "സംഗീത വീഡിയോകൾ", + "NameInstallFailed": "{0} ഇൻസ്റ്റാളേഷൻ പരാജയപ്പെട്ടു", + "NameSeasonNumber": "സീസൺ {0}", + "NameSeasonUnknown": "സീസൺ അജ്ഞാതം", + "NewVersionIsAvailable": "ജെല്ലിഫിൻ സെർവറിന്റെ പുതിയ പതിപ്പ് ഡ .ൺലോഡിനായി ലഭ്യമാണ്.", + "NotificationOptionApplicationUpdateAvailable": "അപ്ലിക്കേഷൻ അപ്ഡേറ്റ് ലഭ്യമാണ്", + "NotificationOptionApplicationUpdateInstalled": "അപ്ലിക്കേഷൻ അപ്ഡേറ്റ് ഇൻസ്റ്റാളുചെയ്തു", + "NotificationOptionAudioPlayback": "ഓഡിയോ പ്ലേബാക്ക് ആരംഭിച്ചു", + "NotificationOptionAudioPlaybackStopped": "ഓഡിയോ പ്ലേബാക്ക് നിർത്തി", + "NotificationOptionCameraImageUploaded": "ക്യാമറ ചിത്രം അപ്ലോഡുചെയ്തു", + "NotificationOptionInstallationFailed": "ഇൻസ്റ്റാളേഷൻ പരാജയം", + "NotificationOptionNewLibraryContent": "പുതിയ ഉള്ളടക്കം ചേർത്തു", + "NotificationOptionPluginError": "പ്ലഗിൻ പരാജയം", + "NotificationOptionPluginInstalled": "പ്ലഗിൻ ഇൻസ്റ്റാളുചെയ്തു", + "NotificationOptionPluginUninstalled": "പ്ലഗിൻ അൺഇൻസ്റ്റാൾ ചെയ്തു", + "NotificationOptionPluginUpdateInstalled": "പ്ലഗിൻ അപ്ഡേറ്റ് ഇൻസ്റ്റാളുചെയ്തു", + "NotificationOptionServerRestartRequired": "സെർവർ പുനരാരംഭിക്കൽ ആവശ്യമാണ്", + "NotificationOptionTaskFailed": "ഷെഡ്യൂൾ ചെയ്ത ടാസ്ക് പരാജയം", + "NotificationOptionUserLockedOut": "ഉപയോക്താവ് ലോക്ക് out ട്ട് ചെയ്തു", + "NotificationOptionVideoPlayback": "വീഡിയോ പ്ലേബാക്ക് ആരംഭിച്ചു", + "NotificationOptionVideoPlaybackStopped": "വീഡിയോ പ്ലേബാക്ക് നിർത്തി", + "Plugin": "പ്ലഗിൻ", + "PluginInstalledWithName": "{0} ഇൻസ്റ്റാളുചെയ്തു", + "PluginUninstalledWithName": "{0 un അൺഇൻസ്റ്റാൾ ചെയ്തു", + "PluginUpdatedWithName": "{0} അപ്ഡേറ്റുചെയ്തു", + "ProviderValue": "ദാതാവ്: {0}", + "ScheduledTaskFailedWithName": "{0} പരാജയപ്പെട്ടു", + "ScheduledTaskStartedWithName": "{0} ആരംഭിച്ചു", + "ServerNameNeedsToBeRestarted": "{0} പുനരാരംഭിക്കേണ്ടതുണ്ട്", + "StartupEmbyServerIsLoading": "ജെല്ലിഫിൻ സെർവർ ലോഡുചെയ്യുന്നു. ഉടൻ തന്നെ വീണ്ടും ശ്രമിക്കുക.", + "SubtitleDownloadFailureFromForItem": "സബ്ടൈറ്റിലുകൾ {1} ന് {0 from ൽ നിന്ന് ഡ download ൺലോഡ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", + "System": "സിസ്റ്റം", + "TvShows": "ടിവി ഷോകൾ", + "Undefined": "നിർവചിച്ചിട്ടില്ല", + "User": "ഉപയോക്താവ്", + "UserCreatedWithName": "ഉപയോക്താവ് {0 created സൃഷ്ടിച്ചു", + "UserDeletedWithName": "ഉപയോക്താവ് {0 deleted ഇല്ലാതാക്കി", + "UserDownloadingItemWithValues": "{0} ഡൗൺലോഡുചെയ്യുന്നു {1}", + "UserLockedOutWithName": "{0} ഉപയോക്താവ് ലോക്ക് out ട്ട് ചെയ്തു", + "UserOfflineFromDevice": "{0} {1} ൽ നിന്ന് വിച്ഛേദിച്ചു", + "UserOnlineFromDevice": "{0} {1} മുതൽ ഓൺലൈനിലാണ്", + "UserPasswordChangedWithName": "{0} ഉപയോക്താവിനായി പാസ്വേഡ് മാറ്റി", + "UserPolicyUpdatedWithName": "{0} എന്നതിനായി ഉപയോക്തൃ നയം അപ്ഡേറ്റുചെയ്തു", + "UserStartedPlayingItemWithValues": "{0} {2} ൽ {1} പ്ലേ ചെയ്യുന്നു", + "UserStoppedPlayingItemWithValues": "{0} {2} ൽ {1 play കളിക്കുന്നത് പൂർത്തിയാക്കി", + "ValueHasBeenAddedToLibrary": "Media 0 your നിങ്ങളുടെ മീഡിയ ലൈബ്രറിയിലേക്ക് ചേർത്തു", + "VersionNumber": "പതിപ്പ് {0}", + "TasksMaintenanceCategory": "പരിപാലനം", + "TasksLibraryCategory": "പുസ്തകശാല", + "TasksApplicationCategory": "അപ്ലിക്കേഷൻ", + "TasksChannelsCategory": "ഇന്റർനെറ്റ് ചാനലുകൾ", + "TaskCleanActivityLog": "പ്രവർത്തന ലോഗ് വൃത്തിയാക്കുക", + "TaskCleanActivityLogDescription": "കോൺഫിഗർ ചെയ്ത പ്രായത്തേക്കാൾ പഴയ പ്രവർത്തന ലോഗ് എൻട്രികൾ ഇല്ലാതാക്കുന്നു.", + "TaskCleanCache": "കാഷെ ഡയറക്ടറി വൃത്തിയാക്കുക", + "TaskCleanCacheDescription": "സിസ്റ്റത്തിന് ഇനി ആവശ്യമില്ലാത്ത കാഷെ ഫയലുകൾ ഇല്ലാതാക്കുന്നു.", + "TaskRefreshChapterImages": "ചാപ്റ്റർ ഇമേജുകൾ എക്സ്ട്രാക്റ്റുചെയ്യുക", + "TaskRefreshChapterImagesDescription": "അധ്യായങ്ങളുള്ള വീഡിയോകൾക്കായി ലഘുചിത്രങ്ങൾ സൃഷ്ടിക്കുന്നു.", + "TaskRefreshLibrary": "മീഡിയ ലൈബ്രറി സ്കാൻ ചെയ്യുക", + "TaskRefreshLibraryDescription": "പുതിയ ഫയലുകൾക്കായി നിങ്ങളുടെ മീഡിയ ലൈബ്രറി സ്കാൻ ചെയ്യുകയും മെറ്റാഡാറ്റ പുതുക്കുകയും ചെയ്യുന്നു.", + "TaskCleanLogs": "ലോഗ് ഡയറക്ടറി വൃത്തിയാക്കുക", + "TaskCleanLogsDescription": "Log 0} ദിവസത്തിൽ കൂടുതൽ പഴക്കമുള്ള ലോഗ് ഫയലുകൾ ഇല്ലാതാക്കുന്നു.", + "TaskRefreshPeople": "ആളുകളെ പുതുക്കുക", + "TaskRefreshPeopleDescription": "നിങ്ങളുടെ മീഡിയ ലൈബ്രറിയിലെ അഭിനേതാക്കൾക്കും സംവിധായകർക്കും മെറ്റാഡാറ്റ അപ്ഡേറ്റുചെയ്യുന്നു.", + "TaskUpdatePlugins": "പ്ലഗിനുകൾ അപ്ഡേറ്റുചെയ്യുക", + "TaskUpdatePluginsDescription": "യാന്ത്രികമായി അപ്ഡേറ്റുചെയ്യുന്നതിന് കോൺഫിഗർ ചെയ്തിരിക്കുന്ന പ്ലഗിനുകൾക്കായുള്ള അപ്ഡേറ്റുകൾ ഡൗൺലോഡുചെയ്യുകയും ഇൻസ്റ്റാളുചെയ്യുകയും ചെയ്യുന്നു.", + "TaskCleanTranscode": "ട്രാൻസ്കോഡ് ഡയറക്ടറി വൃത്തിയാക്കുക", + "TaskCleanTranscodeDescription": "ഒരു ദിവസത്തിൽ കൂടുതൽ പഴക്കമുള്ള ട്രാൻസ്കോഡ് ഫയലുകൾ ഇല്ലാതാക്കുന്നു.", + "TaskRefreshChannels": "ചാനലുകൾ പുതുക്കുക", + "TaskRefreshChannelsDescription": "ഇന്റർനെറ്റ് ചാനൽ വിവരങ്ങൾ പുതുക്കുന്നു.", + "TaskDownloadMissingSubtitles": "നഷ്ടമായ സബ്ടൈറ്റിലുകൾ ഡൗൺലോഡുചെയ്യുക", + "TaskDownloadMissingSubtitlesDescription": "മെറ്റാഡാറ്റ കോൺഫിഗറേഷനെ അടിസ്ഥാനമാക്കി നഷ്ടമായ സബ്ടൈറ്റിലുകൾക്കായി ഇന്റർനെറ്റ് തിരയുന്നു.", + "ValueSpecialEpisodeName": "പ്രത്യേക - {0}", + "Collections": "ശേഖരങ്ങൾ", + "Folders": "ഫോൾഡറുകൾ", + "HeaderAlbumArtists": "കലാകാരന്റെ ആൽബം", + "Sync": "സമന്വയിപ്പിക്കുക", + "Movies": "സിനിമകൾ", + "Photos": "ഫോട്ടോകൾ", + "Albums": "ആൽബങ്ങൾ", + "Playlists": "പ്ലേലിസ്റ്റുകൾ", + "Songs": "ഗാനങ്ങൾ", + "HeaderContinueWatching": "കാണുന്നത് തുടരുക", + "Artists": "കലാകാരന്മാർ", + "Shows": "ഷോകൾ", + "Default": "സ്ഥിരസ്ഥിതി", + "Favorites": "പ്രിയങ്കരങ്ങൾ", + "Books": "പുസ്തകങ്ങൾ", + "Genres": "വിഭാഗങ്ങൾ", + "Channels": "ചാനലുകൾ", + "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്കാൻ ചെയ്തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്തതിന് ശേഷം ഈ ടാസ്ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.", + "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക" +} diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json index b6db2b0f2..fdb4171b5 100644 --- a/Emby.Server.Implementations/Localization/Core/mr.json +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -54,7 +54,6 @@ "ItemAddedWithName": "{0} हे संग्रहालयात जोडले गेले", "HomeVideos": "घरचे व्हिडीयो", "HeaderRecordingGroups": "रेकॉर्डिंग गट", - "HeaderCameraUploads": "कॅमेरा अपलोड", "CameraImageUploadedFrom": "एक नवीन कॅमेरा चित्र {0} येथून अपलोड केले आहे", "Application": "अॅप्लिकेशन", "AppDeviceValues": "अॅप: {0}, यंत्र: {1}", diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index 7f8df1289..b2dcf270c 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -5,7 +5,7 @@ "Artists": "Artis", "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", "Books": "Buku-buku", - "CameraImageUploadedFrom": "Ada gambar dari kamera yang baru dimuat naik melalui {0}", + "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", "Channels": "Saluran", "ChapterNameValue": "Bab {0}", "Collections": "Koleksi", @@ -16,7 +16,6 @@ "Folders": "Fail-fail", "Genres": "Genre-genre", "HeaderAlbumArtists": "Album Artis-artis", - "HeaderCameraUploads": "Muatnaik Kamera", "HeaderContinueWatching": "Terus Menonton", "HeaderFavoriteAlbums": "Album-album Kegemaran", "HeaderFavoriteArtists": "Artis-artis Kegemaran", @@ -40,29 +39,29 @@ "MixedContent": "Kandungan campuran", "Movies": "Filem", "Music": "Muzik", - "MusicVideos": "Video muzik", + "MusicVideos": "Muzik video", "NameInstallFailed": "{0} pemasangan gagal", "NameSeasonNumber": "Musim {0}", "NameSeasonUnknown": "Musim Tidak Diketahui", "NewVersionIsAvailable": "Versi terbaru Jellyfin Server bersedia untuk dimuat turunkan.", "NotificationOptionApplicationUpdateAvailable": "Kemas kini aplikasi telah sedia", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", + "NotificationOptionApplicationUpdateInstalled": "Kemas kini aplikasi telah dipasang", + "NotificationOptionAudioPlayback": "Ulangmain audio bermula", + "NotificationOptionAudioPlaybackStopped": "Ulangmain audio dihentikan", + "NotificationOptionCameraImageUploaded": "Imej kamera telah dimuatnaik", "NotificationOptionInstallationFailed": "Pemasangan gagal", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", + "NotificationOptionNewLibraryContent": "Kandungan baru telah ditambah", + "NotificationOptionPluginError": "Kegagalan plugin", + "NotificationOptionPluginInstalled": "Plugin telah dipasang", "NotificationOptionPluginUninstalled": "Plugin uninstalled", "NotificationOptionPluginUpdateInstalled": "Plugin update installed", "NotificationOptionServerRestartRequired": "Server restart required", "NotificationOptionTaskFailed": "Scheduled task failure", "NotificationOptionUserLockedOut": "User locked out", "NotificationOptionVideoPlayback": "Video playback started", - "NotificationOptionVideoPlaybackStopped": "Video playback stopped", - "Photos": "Photos", - "Playlists": "Playlists", + "NotificationOptionVideoPlaybackStopped": "Ulangmain video dihentikan", + "Photos": "Gambar-gambar", + "Playlists": "Senarai main", "Plugin": "Plugin", "PluginInstalledWithName": "{0} was installed", "PluginUninstalledWithName": "{0} was uninstalled", @@ -72,10 +71,10 @@ "ScheduledTaskStartedWithName": "{0} bermula", "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", "Shows": "Series", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "Songs": "Lagu-lagu", + "StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", + "SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}", "Sync": "Sync", "System": "Sistem", "TvShows": "TV Shows", @@ -83,14 +82,32 @@ "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", "UserDownloadingItemWithValues": "{0} is downloading {1}", - "UserLockedOutWithName": "User {0} has been locked out", - "UserOfflineFromDevice": "{0} has disconnected from {1}", - "UserOnlineFromDevice": "{0} is online from {1}", - "UserPasswordChangedWithName": "Password has been changed for user {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", + "UserLockedOutWithName": "Pengguna {0} telah dikunci", + "UserOfflineFromDevice": "{0} telah terputus dari {1}", + "UserOnlineFromDevice": "{0} berada dalam talian dari {1}", + "UserPasswordChangedWithName": "Kata laluan telah ditukar bagi pengguna {0}", + "UserPolicyUpdatedWithName": "Dasar pengguna telah dikemas kini untuk {0}", "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", "ValueSpecialEpisodeName": "Khas - {0}", - "VersionNumber": "Versi {0}" + "VersionNumber": "Versi {0}", + "TaskCleanActivityLog": "Log Aktiviti Bersih", + "TasksChannelsCategory": "Saluran Internet", + "TasksApplicationCategory": "Aplikasi", + "TasksLibraryCategory": "Perpustakaan", + "TasksMaintenanceCategory": "Penyelenggaraan", + "Undefined": "Tidak ditentukan", + "Forced": "Paksa", + "Default": "Asal", + "TaskCleanCache": "Bersihkan Direktori Cache", + "TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi.", + "TaskRefreshPeople": "Segarkan Orang", + "TaskCleanLogsDescription": "Padamkan fail log yang berumur lebih dari {0} hari.", + "TaskCleanLogs": "Bersihkan Direktotri Log", + "TaskRefreshLibraryDescription": "Imbas perpustakaan media untuk mencari fail-fail baru dan menyegarkan metadata.", + "TaskRefreshLibrary": "Imbas Perpustakaan Media", + "TaskRefreshChapterImagesDescription": "Membuat gambaran kecil untuk video yang mempunyai bab.", + "TaskRefreshChapterImages": "Ekstrak Gambar-gambar Bab", + "TaskCleanCacheDescription": "Menghapuskan fail cache yang tidak lagi diperlukan oleh sistem." } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 1b55c2e38..81c1eefe7 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -16,7 +16,6 @@ "Folders": "Mapper", "Genres": "Sjangre", "HeaderAlbumArtists": "Albumartister", - "HeaderCameraUploads": "Kameraopplastinger", "HeaderContinueWatching": "Fortsett å se", "HeaderFavoriteAlbums": "Favorittalbum", "HeaderFavoriteArtists": "Favorittartister", @@ -31,39 +30,39 @@ "ItemAddedWithName": "{0} ble lagt til i biblioteket", "ItemRemovedWithName": "{0} ble fjernet fra biblioteket", "LabelIpAddressValue": "IP-adresse: {0}", - "LabelRunningTimeValue": "Kjøretid {0}", + "LabelRunningTimeValue": "Spilletid {0}", "Latest": "Siste", - "MessageApplicationUpdated": "Jellyfin Server har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin Server ble oppdatert til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfigurasjon seksjon {0} har blitt oppdatert", - "MessageServerConfigurationUpdated": "Serverkonfigurasjon er oppdatert", + "MessageApplicationUpdated": "Jellyfin-tjeneren har blitt oppdatert", + "MessageApplicationUpdatedTo": "Jellyfin-tjeneren ble oppdatert til {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Tjenerkonfigurasjonsseksjon {0} har blitt oppdatert", + "MessageServerConfigurationUpdated": "Tjenerkonfigurasjon er oppdatert", "MixedContent": "Blandet innhold", "Movies": "Filmer", "Music": "Musikk", "MusicVideos": "Musikkvideoer", - "NameInstallFailed": "{0}-installasjonen mislyktes", + "NameInstallFailed": "Installasjonen av {0} mislyktes", "NameSeasonNumber": "Sesong {0}", - "NameSeasonUnknown": "Sesong ukjent", - "NewVersionIsAvailable": "En ny versjon av Jellyfin Server er tilgjengelig for nedlasting.", - "NotificationOptionApplicationUpdateAvailable": "Programvareoppdatering er tilgjengelig", + "NameSeasonUnknown": "Ukjent sesong", + "NewVersionIsAvailable": "En ny versjon av Jellyfin-tjeneren er tilgjengelig for nedlasting.", + "NotificationOptionApplicationUpdateAvailable": "En programvareoppdatering er tilgjengelig", "NotificationOptionApplicationUpdateInstalled": "Applikasjonsoppdatering installert", "NotificationOptionAudioPlayback": "Lydavspilling startet", "NotificationOptionAudioPlaybackStopped": "Lydavspilling stoppet", "NotificationOptionCameraImageUploaded": "Kamerabilde lastet opp", - "NotificationOptionInstallationFailed": "Installasjonsfeil", + "NotificationOptionInstallationFailed": "Installasjonen feilet", "NotificationOptionNewLibraryContent": "Nytt innhold lagt til", - "NotificationOptionPluginError": "Pluginfeil", - "NotificationOptionPluginInstalled": "Plugin installert", - "NotificationOptionPluginUninstalled": "Plugin avinstallert", - "NotificationOptionPluginUpdateInstalled": "Pluginoppdatering installert", - "NotificationOptionServerRestartRequired": "Serveromstart er nødvendig", + "NotificationOptionPluginError": "Programvareutvidelsesfeil", + "NotificationOptionPluginInstalled": "Programvareutvidelse installert", + "NotificationOptionPluginUninstalled": "Programvareutvidelse avinstallert", + "NotificationOptionPluginUpdateInstalled": "Programvareutvidelsesoppdatering installert", + "NotificationOptionServerRestartRequired": "Tjeneromstart er nødvendig", "NotificationOptionTaskFailed": "Feil under utføring av planlagt oppgave", "NotificationOptionUserLockedOut": "Bruker er utestengt", "NotificationOptionVideoPlayback": "Videoavspilling startet", "NotificationOptionVideoPlaybackStopped": "Videoavspilling stoppet", "Photos": "Bilder", "Playlists": "Spillelister", - "Plugin": "Plugin", + "Plugin": "Programvareutvidelse", "PluginInstalledWithName": "{0} ble installert", "PluginUninstalledWithName": "{0} ble avinstallert", "PluginUpdatedWithName": "{0} ble oppdatert", @@ -71,9 +70,9 @@ "ScheduledTaskFailedWithName": "{0} mislykkes", "ScheduledTaskStartedWithName": "{0} startet", "ServerNameNeedsToBeRestarted": "{0} må startes på nytt", - "Shows": "Programmer", + "Shows": "Program", "Songs": "Sanger", - "StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.", + "StartupEmbyServerIsLoading": "Jellyfin-tjener laster. Prøv igjen snart.", "SubtitleDownloadFailureForItem": "En feil oppstå under nedlasting av undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}", "Sync": "Synkroniser", @@ -87,32 +86,38 @@ "UserOfflineFromDevice": "{0} har koblet fra {1}", "UserOnlineFromDevice": "{0} er tilkoblet fra {1}", "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", - "UserPolicyUpdatedWithName": "Brukerpolicyen har blitt oppdatert for {0}", - "UserStartedPlayingItemWithValues": "{0} har startet avspilling {1}", - "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling {1}", + "UserPolicyUpdatedWithName": "Brukerretningslinjene har blitt oppdatert for {0}", + "UserStartedPlayingItemWithValues": "{0} har startet avspilling {1} på {2}", + "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling {1}", "ValueHasBeenAddedToLibrary": "{0} har blitt lagt til i mediebiblioteket ditt", "ValueSpecialEpisodeName": "Spesialepisode - {0}", "VersionNumber": "Versjon {0}", - "TasksChannelsCategory": "Internett kanaler", + "TasksChannelsCategory": "Internettkanaler", "TasksApplicationCategory": "Applikasjon", "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Vedlikehold", - "TaskCleanCache": "Tøm buffer katalog", + "TaskCleanCache": "Tøm hurtigbuffer", "TaskRefreshLibrary": "Skann mediebibliotek", "TaskRefreshChapterImagesDescription": "Lager forhåndsvisningsbilder for videoer som har kapitler.", - "TaskRefreshChapterImages": "Trekk ut Kapittelbilder", + "TaskRefreshChapterImages": "Trekk ut kapittelbilder", "TaskCleanCacheDescription": "Sletter mellomlagrede filer som ikke lengre trengs av systemet.", - "TaskDownloadMissingSubtitlesDescription": "Søker etter manglende underteksting på nett basert på metadatakonfigurasjon.", - "TaskDownloadMissingSubtitles": "Last ned manglende underteksting", - "TaskRefreshChannelsDescription": "Frisker opp internettkanalinformasjon.", - "TaskRefreshChannels": "Oppfrisk kanaler", + "TaskDownloadMissingSubtitlesDescription": "Søker etter manglende undertekster på nett basert på metadatakonfigurasjon.", + "TaskDownloadMissingSubtitles": "Last ned manglende undertekster", + "TaskRefreshChannelsDescription": "Oppdaterer internettkanalinformasjon.", + "TaskRefreshChannels": "Oppdater kanaler", "TaskCleanTranscodeDescription": "Sletter omkodede filer som er mer enn én dag gamle.", "TaskCleanTranscode": "Tøm transkodingmappe", - "TaskUpdatePluginsDescription": "Laster ned og installerer oppdateringer for utvidelser som er stilt inn til å oppdatere automatisk.", - "TaskUpdatePlugins": "Oppdater utvidelser", + "TaskUpdatePluginsDescription": "Laster ned og installerer oppdateringer for programvareutvidelser som er stilt inn til å oppdatere automatisk.", + "TaskUpdatePlugins": "Oppdater programvareutvidelse", "TaskRefreshPeopleDescription": "Oppdaterer metadata for skuespillere og regissører i mediebiblioteket ditt.", - "TaskRefreshPeople": "Oppfrisk personer", + "TaskRefreshPeople": "Oppdater personer", "TaskCleanLogsDescription": "Sletter loggfiler som er eldre enn {0} dager gamle.", "TaskCleanLogs": "Tøm loggmappe", - "TaskRefreshLibraryDescription": "Skanner mediebibliotekene dine for nye filer og oppdaterer metadata." + "TaskRefreshLibraryDescription": "Skanner mediebibliotekene dine for nye filer og oppdaterer metadata.", + "TaskCleanActivityLog": "Tøm aktivitetslogg", + "Undefined": "Udefinert", + "Forced": "Tvunget", + "Default": "Standard", + "TaskCleanActivityLogDescription": "Sletter oppføringer i aktivitetsloggen som er eldre enn den konfigurerte alderen.", + "TaskOptimizeDatabase": "Optimiser database" } diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json index 38c073709..8e820d40c 100644 --- a/Emby.Server.Implementations/Localization/Core/ne.json +++ b/Emby.Server.Implementations/Localization/Core/ne.json @@ -41,7 +41,6 @@ "HeaderFavoriteArtists": "मनपर्ने कलाकारहरू", "HeaderFavoriteAlbums": "मनपर्ने एल्बमहरू", "HeaderContinueWatching": "हेर्न जारी राख्नुहोस्", - "HeaderCameraUploads": "क्यामेरा अपलोडहरू", "HeaderAlbumArtists": "एल्बमका कलाकारहरू", "Genres": "विधाहरू", "Folders": "फोल्डरहरू", diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 41c74d54d..f79840c78 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -1,11 +1,11 @@ { "Albums": "Albums", "AppDeviceValues": "App: {0}, Apparaat: {1}", - "Application": "Programma", + "Application": "Toepassing", "Artists": "Artiesten", - "AuthenticationSucceededWithUserName": "{0} is succesvol geverifieerd", + "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", "Books": "Boeken", - "CameraImageUploadedFrom": "Er is een nieuwe camera afbeelding toegevoegd via {0}", + "CameraImageUploadedFrom": "Nieuwe camera afbeelding toegevoegd vanaf {0}", "Channels": "Kanalen", "ChapterNameValue": "Hoofdstuk {0}", "Collections": "Verzamelingen", @@ -16,7 +16,6 @@ "Folders": "Mappen", "Genres": "Genres", "HeaderAlbumArtists": "Albumartiesten", - "HeaderCameraUploads": "Camera-uploads", "HeaderContinueWatching": "Kijken hervatten", "HeaderFavoriteAlbums": "Favoriete albums", "HeaderFavoriteArtists": "Favoriete artiesten", @@ -26,8 +25,8 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Volgende", "HeaderRecordingGroups": "Opnamegroepen", - "HomeVideos": "Home video's", - "Inherit": "Overerven", + "HomeVideos": "Thuis video's", + "Inherit": "Erven", "ItemAddedWithName": "{0} is toegevoegd aan de bibliotheek", "ItemRemovedWithName": "{0} is verwijderd uit de bibliotheek", "LabelIpAddressValue": "IP-adres: {0}", @@ -88,16 +87,16 @@ "UserOnlineFromDevice": "{0} heeft verbinding met {1}", "UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd", "UserPolicyUpdatedWithName": "Gebruikersbeleid gewijzigd voor {0}", - "UserStartedPlayingItemWithValues": "{0} heeft afspelen van {1} gestart op {2}", + "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}", "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}", "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek", "ValueSpecialEpisodeName": "Speciaal - {0}", "VersionNumber": "Versie {0}", - "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar missende ondertitels gebaseerd op metadata configuratie.", - "TaskDownloadMissingSubtitles": "Download missende ondertitels", + "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar missende ondertiteling gebaseerd op metadata configuratie.", + "TaskDownloadMissingSubtitles": "Download missende ondertiteling", "TaskRefreshChannelsDescription": "Vernieuwt informatie van internet kanalen.", "TaskRefreshChannels": "Vernieuw Kanalen", - "TaskCleanTranscodeDescription": "Verwijder transcode bestanden ouder dan 1 dag.", + "TaskCleanTranscodeDescription": "Verwijdert transcode bestanden ouder dan 1 dag.", "TaskCleanLogs": "Log Folder Opschonen", "TaskCleanTranscode": "Transcode Folder Opschonen", "TaskUpdatePluginsDescription": "Download en installeert updates voor plugins waar automatisch updaten aan staat.", @@ -109,10 +108,17 @@ "TaskRefreshLibrary": "Scan Media Bibliotheek", "TaskRefreshChapterImagesDescription": "Maakt thumbnails aan voor videos met hoofdstukken.", "TaskRefreshChapterImages": "Hoofdstukafbeeldingen Uitpakken", - "TaskCleanCacheDescription": "Verwijder gecachte bestanden die het systeem niet langer nodig heeft.", + "TaskCleanCacheDescription": "Verwijdert gecachte bestanden die het systeem niet langer nodig heeft.", "TaskCleanCache": "Cache Folder Opschonen", "TasksChannelsCategory": "Internet Kanalen", "TasksApplicationCategory": "Applicatie", "TasksLibraryCategory": "Bibliotheek", - "TasksMaintenanceCategory": "Onderhoud" + "TasksMaintenanceCategory": "Onderhoud", + "TaskCleanActivityLogDescription": "Verwijdert activiteiten logs ouder dan de ingestelde tijd.", + "TaskCleanActivityLog": "Leeg activiteiten logboek", + "Undefined": "Niet gedefinieerd", + "Forced": "Geforceerd", + "Default": "Standaard", + "TaskOptimizeDatabaseDescription": "Comprimeert de database en trimt vrije ruimte. Het uitvoeren van deze taak kan de prestaties verbeteren, na het scannen van de bibliotheek of andere aanpassingen die invloed hebben op de database.", + "TaskOptimizeDatabase": "Database optimaliseren" } diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json index 281cadac5..32d4f3a8b 100644 --- a/Emby.Server.Implementations/Localization/Core/nn.json +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -1,26 +1,25 @@ { - "MessageServerConfigurationUpdated": "Tenar konfigurasjonen har blitt oppdatert", + "MessageServerConfigurationUpdated": "Tenarkonfigurasjonen har blitt oppdatert", "MessageNamedServerConfigurationUpdatedWithValue": "Tenar konfigurasjon seksjon {0} har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin Tenaren har blitt oppdatert til {0}", - "MessageApplicationUpdated": "Jellyfin Tenaren har blitt oppdatert", + "MessageApplicationUpdatedTo": "Jellyfin-tenaren har blitt oppdatert til {0}", + "MessageApplicationUpdated": "Jellyfin-tenaren har blitt oppdatert", "Latest": "Nyaste", "LabelRunningTimeValue": "Speletid: {0}", - "LabelIpAddressValue": "IP adresse: {0}", + "LabelIpAddressValue": "IP-adresse: {0}", "ItemRemovedWithName": "{0} vart fjerna frå biblioteket", "ItemAddedWithName": "{0} vart lagt til i biblioteket", - "Inherit": "Arv", - "HomeVideos": "Heime Videoar", + "Inherit": "Arve", + "HomeVideos": "Heimevideoar", "HeaderRecordingGroups": "Innspelingsgrupper", "HeaderNextUp": "Neste", "HeaderLiveTV": "Direkte TV", - "HeaderFavoriteSongs": "Favoritt Songar", - "HeaderFavoriteShows": "Favoritt Seriar", - "HeaderFavoriteEpisodes": "Favoritt Episodar", - "HeaderFavoriteArtists": "Favoritt Artistar", - "HeaderFavoriteAlbums": "Favoritt Album", + "HeaderFavoriteSongs": "Favorittsongar", + "HeaderFavoriteShows": "Favorittseriar", + "HeaderFavoriteEpisodes": "Favorittepisodar", + "HeaderFavoriteArtists": "Favorittartistar", + "HeaderFavoriteAlbums": "Favorittalbum", "HeaderContinueWatching": "Fortsett å sjå", - "HeaderCameraUploads": "Kamera Opplastingar", - "HeaderAlbumArtists": "Album Artist", + "HeaderAlbumArtists": "Albumartist", "Genres": "Sjangrar", "Folders": "Mapper", "Favorites": "Favorittar", @@ -30,31 +29,93 @@ "Collections": "Samlingar", "ChapterNameValue": "Kapittel {0}", "Channels": "Kanalar", - "CameraImageUploadedFrom": "Eit nytt kamera bilete har blitt lasta opp frå {0}", + "CameraImageUploadedFrom": "Eit nytt kamerabilete har blitt lasta opp frå {0}", "Books": "Bøker", - "AuthenticationSucceededWithUserName": "{0} Har logga inn", + "AuthenticationSucceededWithUserName": "{0} har logga inn", "Artists": "Artistar", "Application": "Program", - "AppDeviceValues": "App: {0}, Einheit: {1}", + "AppDeviceValues": "App: {0}, Eining: {1}", "Albums": "Album", "NotificationOptionServerRestartRequired": "Tenaren krev omstart", - "NotificationOptionPluginUpdateInstalled": "Tilleggsprogram-oppdatering vart installert", - "NotificationOptionPluginUninstalled": "Tilleggsprogram avinstallert", - "NotificationOptionPluginInstalled": "Tilleggsprogram installert", - "NotificationOptionPluginError": "Tilleggsprogram feila", + "NotificationOptionPluginUpdateInstalled": "Programvaretilleggoppdatering vart installert", + "NotificationOptionPluginUninstalled": "Programvaretillegg avinstallert", + "NotificationOptionPluginInstalled": "Programvaretillegg installert", + "NotificationOptionPluginError": "Programvaretillegg feila", "NotificationOptionNewLibraryContent": "Nytt innhald er lagt til", - "NotificationOptionInstallationFailed": "Installasjonen feila", + "NotificationOptionInstallationFailed": "Installasjonsfeil", "NotificationOptionCameraImageUploaded": "Kamerabilde vart lasta opp", "NotificationOptionAudioPlaybackStopped": "Lydavspilling stoppa", "NotificationOptionAudioPlayback": "Lydavspilling påbyrja", "NotificationOptionApplicationUpdateInstalled": "Applikasjonsoppdatering er installert", "NotificationOptionApplicationUpdateAvailable": "Applikasjonsoppdatering er tilgjengeleg", - "NewVersionIsAvailable": "Ein ny versjon av Jellyfin serveren er tilgjengeleg for nedlasting.", + "NewVersionIsAvailable": "Ein ny versjon av Jellyfin-tjenaren er tilgjengeleg for nedlasting.", "NameSeasonUnknown": "Ukjend sesong", "NameSeasonNumber": "Sesong {0}", - "NameInstallFailed": "{0} Installasjonen feila", + "NameInstallFailed": "Installasjonen av {0} feila", "MusicVideos": "Musikkvideoar", "Music": "Musikk", "Movies": "Filmar", - "MixedContent": "Blanda innhald" + "MixedContent": "Blanda innhald", + "Sync": "Synkroniser", + "TaskDownloadMissingSubtitlesDescription": "Søk Internettet for manglande undertekstar basert på metadatainnstillingar.", + "TaskDownloadMissingSubtitles": "Last ned manglande undertekstar", + "TaskRefreshChannelsDescription": "Oppdater internettkanalinformasjon.", + "TaskRefreshChannels": "Oppdater kanalar", + "TaskCleanTranscodeDescription": "Slett transkodefiler som er meir enn ein dag gammal.", + "TaskCleanTranscode": "Fjern transkodemappe", + "TaskUpdatePluginsDescription": "Laster ned og installerer oppdateringar for programtillegg som er sette opp til å oppdaterast automatisk.", + "TaskUpdatePlugins": "Oppdaterer programvaretillegg", + "TaskRefreshPeopleDescription": "Oppdaterer metadata for skodespelarar og regissørar i mediebiblioteket ditt.", + "TaskRefreshPeople": "Oppdater personar", + "TaskCleanLogsDescription": "Slett loggfiler som er meir enn {0} dagar gamle.", + "TaskCleanLogs": "Slett loggmappa", + "TaskRefreshLibraryDescription": "Skannar mediebiblioteket ditt for nye filer og oppdaterer metadata.", + "TaskRefreshLibrary": "Skann mediebibliotek", + "TaskRefreshChapterImagesDescription": "Lager miniatyrbilete for videoar som har kapittel.", + "TaskRefreshChapterImages": "Trekk ut kapittelbilete", + "TaskCleanCacheDescription": "Sletter mellomlagra filer som ikkje lengre trengst av systemet.", + "TaskCleanCache": "Fjern hurtigbuffer", + "TasksChannelsCategory": "Internettkanalar", + "TasksApplicationCategory": "Applikasjon", + "TasksLibraryCategory": "Bibliotek", + "TasksMaintenanceCategory": "Vedlikehald", + "VersionNumber": "Versjon {0}", + "ValueSpecialEpisodeName": "Spesialepisode - {0}", + "ValueHasBeenAddedToLibrary": "{0} har blitt lagt til i mediebiblioteket ditt", + "UserStoppedPlayingItemWithValues": "{0} har fullført avspeling {1} på {2}", + "UserStartedPlayingItemWithValues": "{0} spelar {1} på {2}", + "UserPolicyUpdatedWithName": "Brukarreglar har blitt oppdatert for {0}", + "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", + "UserOnlineFromDevice": "{0} er direktekopla frå {1}", + "UserOfflineFromDevice": "{0} har kopla frå {1}", + "UserLockedOutWithName": "Brukar {0} har blitt utestengd", + "UserDownloadingItemWithValues": "{0} lastar ned {1}", + "UserDeletedWithName": "Brukar {0} er sletta", + "UserCreatedWithName": "Brukar {0} er oppretta", + "User": "Brukar", + "TvShows": "TV-seriar", + "System": "System", + "SubtitleDownloadFailureFromForItem": "Feila å laste ned undertekstar frå {0} for {1}", + "StartupEmbyServerIsLoading": "Jellyfin-tenaren laster. Prøv igjen seinare.", + "Songs": "Sangar", + "Shows": "Seriar", + "ServerNameNeedsToBeRestarted": "{0} må omstartast", + "ScheduledTaskStartedWithName": "{0} starta", + "ScheduledTaskFailedWithName": "{0} feila", + "ProviderValue": "Leverandør: {0}", + "PluginUpdatedWithName": "{0} blei oppdatert", + "PluginUninstalledWithName": "{0} blei avinstallert", + "PluginInstalledWithName": "{0} blei installert", + "Plugin": "Programvaretillegg", + "Playlists": "Spelelister", + "Photos": "Bilete", + "NotificationOptionVideoPlaybackStopped": "Videoavspeling stoppa", + "NotificationOptionVideoPlayback": "Videoavspeling starta", + "NotificationOptionUserLockedOut": "Brukar er utestengd", + "NotificationOptionTaskFailed": "Planlagt oppgåve feila", + "TaskCleanActivityLogDescription": "Sletter aktivitetslogginnlegg som er eldre enn den konfigurerte alderen.", + "TaskCleanActivityLog": "Slett aktivitetslogg", + "Undefined": "Udefinert", + "Forced": "Tvungen", + "Default": "Standard" } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json new file mode 100644 index 000000000..d1db09232 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -0,0 +1,121 @@ +{ + "TaskRefreshChapterImages": "ਐਕਸਟਰੈਕਟ ਚੈਪਟਰ ਚਿੱਤਰ", + "TaskDownloadMissingSubtitlesDescription": "ਮੈਟਾਡੇਟਾ ਕੌਂਫਿਗਰੇਸ਼ਨ ਦੇ ਅਧਾਰ ਤੇ ਗਾਇਬ ਉਪਸਿਰਲੇਖਾਂ ਲਈ ਇੰਟਰਨੈਟ ਦੀ ਭਾਲ ਕਰਦਾ ਹੈ.", + "TaskDownloadMissingSubtitles": "ਗਾਇਬ ਉਪਸਿਰਲੇਖ ਡਾ Download ਨਲੋਡ ਕਰੋ", + "TaskRefreshChannelsDescription": "ਇੰਟਰਨੈੱਟ ਚੈਨਲ ਦੀ ਜਾਣਕਾਰੀ ਨੂੰ ਤਾਜ਼ਾ ਕਰਦਾ ਹੈ.", + "TaskRefreshChannels": "ਚੈਨਲਾਂ ਨੂੰ ਤਾਜ਼ਾ ਕਰੋ", + "TaskCleanTranscodeDescription": "ਇੱਕ ਦਿਨ ਤੋਂ ਵੱਧ ਪੁਰਾਣੀ ਟ੍ਰਾਂਸਕੋਡ ਫਾਈਲਾਂ ਨੂੰ ਮਿਟਾਉਂਦਾ ਹੈ.", + "TaskCleanTranscode": "ਕਲੀਨ ਟ੍ਰਾਂਸਕੋਡ ਡਾਇਰੈਕਟਰੀ", + "TaskUpdatePluginsDescription": "ਪਲਗਇੰਸਾਂ ਲਈ ਡਾਉਨਲੋਡ ਅਤੇ ਸਥਾਪਨਾ ਅਪਡੇਟਾਂ ਜੋ ਆਪਣੇ ਆਪ ਅਪਡੇਟ ਕਰਨ ਲਈ ਕੌਂਫਿਗਰ ਕੀਤੀਆਂ ਜਾਂਦੀਆਂ ਹਨ.", + "TaskUpdatePlugins": "ਪਲੱਗਇਨ ਅਪਡੇਟ ਕਰੋ", + "TaskRefreshPeopleDescription": "ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਅਦਾਕਾਰਾਂ ਅਤੇ ਨਿਰਦੇਸ਼ਕਾਂ ਲਈ ਮੈਟਾਡੇਟਾ ਨੂੰ ਅਪਡੇਟ ਕਰਦਾ ਹੈ.", + "TaskRefreshPeople": "ਲੋਕਾਂ ਨੂੰ ਤਾਜ਼ਾ ਕਰੋ", + "TaskCleanLogsDescription": "ਲੌਗ ਫਾਈਲਾਂ ਨੂੰ ਮਿਟਾਉਂਦਾ ਹੈ ਜੋ {0} ਦਿਨਾਂ ਤੋਂ ਵੱਧ ਪੁਰਾਣੀਆਂ ਹਨ.", + "TaskCleanLogs": "ਕਲੀਨ ਲਾਗ ਡਾਇਰੈਕਟਰੀ", + "TaskRefreshLibraryDescription": "ਨਵੀਆਂ ਫਾਈਲਾਂ ਲਈ ਆਪਣੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਸਕੈਨ ਕਰਦਾ ਹੈ ਅਤੇ ਮੈਟਾਡੇਟਾ ਨੂੰ ਤਾਜ਼ਾ ਕਰਦਾ ਹੈ.", + "TaskRefreshLibrary": "ਸਕੈਨ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ", + "TaskRefreshChapterImagesDescription": "ਚੈਪਟਰਾਂ ਵਾਲੇ ਵੀਡੀਓ ਲਈ ਥੰਬਨੇਲ ਬਣਾਉਂਦੇ ਹਨ.", + "TaskCleanCacheDescription": "ਸਿਸਟਮ ਦੁਆਰਾ ਹੁਣ ਕੈਚੇ ਫਾਈਲਾਂ ਦੀ ਜਰੂਰਤ ਨਹੀਂ ਹੈ.", + "TaskCleanCache": "ਸਾਫ਼ ਕੈਸ਼ ਡਾਇਰੈਕਟਰੀ", + "TaskCleanActivityLogDescription": "ਕੌਂਫਿਗਰ ਕੀਤੀ ਉਮਰ ਤੋਂ ਪੁਰਾਣੀ ਗਤੀਵਿਧੀ ਲੌਗ ਐਂਟਰੀਜ ਨੂੰ ਮਿਟਾਉਂਦਾ ਹੈ.", + "TaskCleanActivityLog": "ਸਾਫ਼ ਗਤੀਵਿਧੀ ਲਾਗ", + "TasksChannelsCategory": "ਇੰਟਰਨੈੱਟ ਚੈਨਲ", + "TasksApplicationCategory": "ਐਪਲੀਕੇਸ਼ਨ", + "TasksLibraryCategory": "ਲਾਇਬ੍ਰੇਰੀ", + "TasksMaintenanceCategory": "ਰੱਖ-ਰਖਾਅ", + "VersionNumber": "ਵਰਜਨ {0}", + "ValueSpecialEpisodeName": "ਵਿਸ਼ੇਸ਼ - {0}", + "ValueHasBeenAddedToLibrary": "{0} ਤੁਹਾਡੀ ਮੀਡੀਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਹੈ", + "UserStoppedPlayingItemWithValues": "{0} ਨੇ {2} 'ਤੇ {1} ਖੇਡਣਾ ਪੂਰਾ ਕਰ ਲਿਆ ਹੈ", + "UserStartedPlayingItemWithValues": "{0} {2} 'ਤੇ {1} ਖੇਡ ਰਿਹਾ ਹੈ", + "UserPolicyUpdatedWithName": "ਉਪਭੋਗਤਾ ਨੀਤੀ ਨੂੰ {0} ਲਈ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "UserPasswordChangedWithName": "ਪਾਸਵਰਡ ਯੂਜ਼ਰ ਲਈ ਬਦਲਿਆ ਗਿਆ ਹੈ {0}", + "UserOnlineFromDevice": "{0} ਤੋਂ isਨਲਾਈਨ ਹੈ {1}", + "UserOfflineFromDevice": "{0} ਤੋਂ ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ {1}", + "UserLockedOutWithName": "ਯੂਜ਼ਰ {0} ਨੂੰ ਲਾਕ ਆਉਟ ਕਰ ਦਿੱਤਾ ਗਿਆ ਹੈ", + "UserDownloadingItemWithValues": "{0} ਡਾ{ਨਲੋਡ ਕਰ ਰਿਹਾ ਹੈ {1}", + "UserDeletedWithName": "ਯੂਜ਼ਰ {0} ਨੂੰ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ", + "UserCreatedWithName": "ਯੂਜ਼ਰ {0} ਬਣਾਇਆ ਗਿਆ ਹੈ", + "User": "ਯੂਜ਼ਰ", + "Undefined": "ਪਰਿਭਾਸ਼ਤ", + "TvShows": "ਟੀਵੀ ਸ਼ੋਅਜ਼", + "System": "ਸਿਸਟਮ", + "Sync": "ਸਿੰਕ", + "SubtitleDownloadFailureFromForItem": "ਉਪਸਿਰਲੇਖ {1} ਲਈ {0} ਤੋਂ ਡਾ toਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ", + "StartupEmbyServerIsLoading": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ. ਕਿਰਪਾ ਕਰਕੇ ਜਲਦੀ ਹੀ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ.", + "Songs": "ਗਾਣੇ", + "Shows": "ਸ਼ੋਅਜ਼", + "ServerNameNeedsToBeRestarted": "{0} ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", + "ScheduledTaskStartedWithName": "{0} ਸ਼ੁਰੂ ਹੋਇਆ", + "ScheduledTaskFailedWithName": "{0} ਅਸਫਲ", + "ProviderValue": "ਦੇਣ ਵਾਲੇ: {0}", + "PluginUpdatedWithName": "{0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਸੀ", + "PluginUninstalledWithName": "{0} ਅਣਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ ਸੀ", + "PluginInstalledWithName": "{0} ਲਗਾਇਆ ਗਿਆ ਸੀ", + "Plugin": "ਪਲੱਗਇਨ", + "Playlists": "ਪਲੇਲਿਸਟਸ", + "Photos": "ਫੋਟੋਆਂ", + "NotificationOptionVideoPlaybackStopped": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਰੋਕਿਆ ਗਿਆ", + "NotificationOptionVideoPlayback": "ਵੀਡੀਓ ਪਲੇਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", + "NotificationOptionUserLockedOut": "ਉਪਭੋਗਤਾ ਨੂੰ ਲਾਕ ਆਉਟ ਕੀਤਾ ਗਿਆ", + "NotificationOptionTaskFailed": "ਨਿਰਧਾਰਤ ਕਾਰਜ ਅਸਫਲਤਾ", + "NotificationOptionServerRestartRequired": "ਸਰਵਰ ਨੂੰ ਮੁੜ ਚਾਲੂ ਕਰਨ ਦੀ ਲੋੜ ਹੈ", + "NotificationOptionPluginUpdateInstalled": "ਪਲੱਗਇਨ ਅਪਡੇਟ ਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ", + "NotificationOptionPluginUninstalled": "ਪਲੱਗਇਨ ਅਣਇੰਸਟੌਲ ਕੀਤਾ", + "NotificationOptionPluginInstalled": "ਪਲੱਗਇਨ ਸਥਾਪਿਤ ਕੀਤਾ", + "NotificationOptionPluginError": "ਪਲੱਗਇਨ ਅਸਫਲ", + "NotificationOptionNewLibraryContent": "ਨਵੀਂ ਸਮੱਗਰੀ ਸ਼ਾਮਲ ਕੀਤੀ ਗਈ", + "NotificationOptionInstallationFailed": "ਇੰਸਟਾਲੇਸ਼ਨ ਅਸਫਲ", + "NotificationOptionCameraImageUploaded": "ਕੈਮਰਾ ਤਸਵੀਰ ਅਪਲੋਡ ਕੀਤੀ ਗਈ", + "NotificationOptionAudioPlaybackStopped": "ਆਡੀਓ ਪਲੇਅਬੈਕ ਰੋਕਿਆ ਗਿਆ", + "NotificationOptionAudioPlayback": "ਆਡੀਓ ਪਲੇਅਬੈਕ ਸ਼ੁਰੂ ਹੋਇਆ", + "NotificationOptionApplicationUpdateInstalled": "ਐਪਲੀਕੇਸ਼ਨ ਅਪਡੇਟ ਇੰਸਟੌਲ ਕੀਤਾ ਗਿਆ", + "NotificationOptionApplicationUpdateAvailable": "ਐਪਲੀਕੇਸ਼ਨ ਅਪਡੇਟ ਉਪਲਬਧ ਹੈ", + "NewVersionIsAvailable": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਦਾ ਨਵਾਂ ਸੰਸਕਰਣ ਡਾਉਨਲੋਡ ਲਈ ਉਪਲਬਧ ਹੈ.", + "NameSeasonUnknown": "ਸੀਜ਼ਨ ਅਣਜਾਣ", + "NameSeasonNumber": "ਸੀਜ਼ਨ {0}", + "NameInstallFailed": "{0} ਇੰਸਟਾਲੇਸ਼ਨ ਫੇਲ੍ਹ ਹੋਈ", + "MusicVideos": "ਸੰਗੀਤ ਵੀਡੀਓ", + "Music": "ਸੰਗੀਤ", + "Movies": "ਫਿਲਮਾਂ", + "MixedContent": "ਮਿਸ਼ਰਤ ਸਮੱਗਰੀ", + "MessageServerConfigurationUpdated": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਨੂੰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "MessageNamedServerConfigurationUpdatedWithValue": "ਸਰਵਰ ਕੌਂਫਿਗਰੇਸ਼ਨ ਸੈਕਸ਼ਨ {0} ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "MessageApplicationUpdatedTo": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਨੂੰ ਅੱਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ {0}", + "MessageApplicationUpdated": "ਜੈਲੀਫਿਨ ਸਰਵਰ ਅਪਡੇਟ ਕੀਤਾ ਗਿਆ ਹੈ", + "Latest": "ਤਾਜ਼ਾ", + "LabelRunningTimeValue": "ਚੱਲਦਾ ਸਮਾਂ: {0}", + "LabelIpAddressValue": "IP ਪਤਾ: {0}", + "ItemRemovedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਸੀ", + "ItemAddedWithName": "{0} ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ ਸੀ", + "Inherit": "ਵਿਰਾਸਤ", + "HomeVideos": "ਘਰੇਲੂ ਵੀਡੀਓ", + "HeaderRecordingGroups": "ਰਿਕਾਰਡਿੰਗ ਸਮੂਹ", + "HeaderNextUp": "ਅੱਗੇ", + "HeaderLiveTV": "ਲਾਈਵ ਟੀ", + "HeaderFavoriteSongs": "ਮਨਪਸੰਦ ਗਾਣੇ", + "HeaderFavoriteShows": "ਮਨਪਸੰਦ ਸ਼ੋਅ", + "HeaderFavoriteEpisodes": "ਮਨਪਸੰਦ ਐਪੀਸੋਡ", + "HeaderFavoriteArtists": "ਮਨਪਸੰਦ ਕਲਾਕਾਰ", + "HeaderFavoriteAlbums": "ਮਨਪਸੰਦ ਐਲਬਮ", + "HeaderContinueWatching": "ਵੇਖਣਾ ਜਾਰੀ ਰੱਖੋ", + "HeaderAlbumArtists": "ਐਲਬਮ ਕਲਾਕਾਰ", + "Genres": "ਸ਼ੈਲੀਆਂ", + "Forced": "ਮਜਬੂਰ", + "Folders": "ਫੋਲਡਰ", + "Favorites": "ਮਨਪਸੰਦ", + "FailedLoginAttemptWithUserName": "ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ {0}", + "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", + "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", + "Default": "ਮੂਲ", + "Collections": "ਸੰਗ੍ਰਹਿ", + "ChapterNameValue": "ਅਧਿਆਇ {0}", + "Channels": "ਚੈਨਲ", + "CameraImageUploadedFrom": "ਤੋਂ ਇੱਕ ਨਵਾਂ ਕੈਮਰਾ ਚਿੱਤਰ ਅਪਲੋਡ ਕੀਤਾ ਗਿਆ ਹੈ {0}", + "Books": "ਕਿਤਾਬਾਂ", + "AuthenticationSucceededWithUserName": "{0} ਸਫਲਤਾਪੂਰਕ ਪ੍ਰਮਾਣਿਤ", + "Artists": "ਕਲਾਕਾਰ", + "Application": "ਐਪਲੀਕੇਸ਼ਨ", + "AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}", + "Albums": "ਐਲਬਮਾਂ" +} diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index bdc0d0169..275bdec6e 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -16,7 +16,6 @@ "Folders": "Foldery", "Genres": "Gatunki", "HeaderAlbumArtists": "Wykonawcy albumów", - "HeaderCameraUploads": "Przekazane obrazy", "HeaderContinueWatching": "Kontynuuj odtwarzanie", "HeaderFavoriteAlbums": "Ulubione albumy", "HeaderFavoriteArtists": "Ulubieni wykonawcy", @@ -114,5 +113,11 @@ "TasksChannelsCategory": "Kanały internetowe", "TasksApplicationCategory": "Aplikacja", "TasksLibraryCategory": "Biblioteka", - "TasksMaintenanceCategory": "Konserwacja" + "TasksMaintenanceCategory": "Konserwacja", + "TaskCleanActivityLogDescription": "Usuwa wpisy dziennika aktywności starsze niż skonfigurowany wiek.", + "TaskCleanActivityLog": "Czyść dziennik aktywności", + "Undefined": "Nieustalony", + "Forced": "Wymuszony", + "Default": "Domyślne", + "TaskOptimizeDatabase": "Optymalizuj bazę danych" } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -0,0 +1 @@ +{} diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 275195640..323dcced0 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -16,8 +16,7 @@ "Folders": "Pastas", "Genres": "Gêneros", "HeaderAlbumArtists": "Artistas do Álbum", - "HeaderCameraUploads": "Envios da Câmera", - "HeaderContinueWatching": "Continuar Assistindo", + "HeaderContinueWatching": "Continuar assistindo", "HeaderFavoriteAlbums": "Álbuns Favoritos", "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episódios favoritos", @@ -114,5 +113,10 @@ "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicativo", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Manutenção" + "TasksMaintenanceCategory": "Manutenção", + "TaskCleanActivityLogDescription": "Apaga o registro de atividades mais antigo que a idade configurada.", + "TaskCleanActivityLog": "Limpar Registro de Atividades", + "Undefined": "Indefinido", + "Forced": "Forçado", + "Default": "Padrão" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index c1fb65743..8c41edf96 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -16,7 +16,6 @@ "Folders": "Pastas", "Genres": "Géneros", "HeaderAlbumArtists": "Artistas do Álbum", - "HeaderCameraUploads": "Envios a partir da câmara", "HeaderContinueWatching": "Continuar a Ver", "HeaderFavoriteAlbums": "Álbuns Favoritos", "HeaderFavoriteArtists": "Artistas Favoritos", @@ -26,7 +25,7 @@ "HeaderLiveTV": "TV em Direto", "HeaderNextUp": "A Seguir", "HeaderRecordingGroups": "Grupos de Gravação", - "HomeVideos": "Videos caseiros", + "HomeVideos": "Vídeos Caseiros", "Inherit": "Herdar", "ItemAddedWithName": "{0} foi adicionado à biblioteca", "ItemRemovedWithName": "{0} foi removido da biblioteca", @@ -114,5 +113,10 @@ "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicação", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Manutenção" + "TasksMaintenanceCategory": "Manutenção", + "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.", + "TaskCleanActivityLog": "Limpar registo de atividade", + "Undefined": "Indefinido", + "Forced": "Forçado", + "Default": "Padrão" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index b534d0bbe..b435672ad 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -61,7 +61,7 @@ "NameSeasonUnknown": "Temporada Desconhecida", "NameSeasonNumber": "Temporada {0}", "NameInstallFailed": "Falha na instalação de {0}", - "MusicVideos": "Videoclips", + "MusicVideos": "Videoclipes", "Music": "Música", "MixedContent": "Conteúdo Misto", "MessageServerConfigurationUpdated": "A configuração do servidor foi actualizada", @@ -83,7 +83,6 @@ "Playlists": "Listas de Reprodução", "Photos": "Fotografias", "Movies": "Filmes", - "HeaderCameraUploads": "Carregamentos a partir da câmara", "FailedLoginAttemptWithUserName": "Tentativa de ligação falhada a partir de {0}", "DeviceOnlineWithName": "{0} está connectado", "DeviceOfflineWithName": "{0} desconectou-se", @@ -113,5 +112,10 @@ "TaskUpdatePluginsDescription": "Download e instala as atualizações para plug-ins configurados para atualização automática.", "TaskRefreshPeopleDescription": "Atualiza os metadados para atores e diretores na tua biblioteca de media.", "TaskRefreshPeople": "Atualizar pessoas", - "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados." + "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados.", + "TaskCleanActivityLog": "Limpar registo de atividade", + "Undefined": "Indefinido", + "Forced": "Forçado", + "Default": "Predefinição", + "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado." } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index 699dd26da..510aac11c 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -74,7 +74,6 @@ "HeaderFavoriteArtists": "Artiști Favoriți", "HeaderFavoriteAlbums": "Albume Favorite", "HeaderContinueWatching": "Vizionează în continuare", - "HeaderCameraUploads": "Incărcări Cameră Foto", "HeaderAlbumArtists": "Album Artiști", "Genres": "Genuri", "Folders": "Dosare", @@ -113,5 +112,10 @@ "TasksChannelsCategory": "Canale de pe Internet", "TasksApplicationCategory": "Aplicație", "TasksLibraryCategory": "Librărie", - "TasksMaintenanceCategory": "Mentenanță" + "TasksMaintenanceCategory": "Mentenanță", + "TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.", + "TaskCleanActivityLog": "Curăță Jurnalul de Activitate", + "Undefined": "Nedefinit", + "Forced": "Forțat", + "Default": "Implicit" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 648aa384b..cd016b51b 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -16,7 +16,6 @@ "Folders": "Папки", "Genres": "Жанры", "HeaderAlbumArtists": "Исполнители альбома", - "HeaderCameraUploads": "Камеры", "HeaderContinueWatching": "Продолжение просмотра", "HeaderFavoriteAlbums": "Избранные альбомы", "HeaderFavoriteArtists": "Избранные исполнители", @@ -26,7 +25,7 @@ "HeaderLiveTV": "Эфир", "HeaderNextUp": "Очередное", "HeaderRecordingGroups": "Группы записей", - "HomeVideos": "Домашнее видео", + "HomeVideos": "Домашние видео", "Inherit": "Наследуемое", "ItemAddedWithName": "{0} - добавлено в медиатеку", "ItemRemovedWithName": "{0} - изъято из медиатеки", @@ -40,7 +39,7 @@ "MixedContent": "Смешанное содержимое", "Movies": "Кино", "Music": "Музыка", - "MusicVideos": "Музыкальные клипы", + "MusicVideos": "Муз. видео", "NameInstallFailed": "Установка {0} неудачна", "NameSeasonNumber": "Сезон {0}", "NameSeasonUnknown": "Сезон неопознан", @@ -76,7 +75,7 @@ "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", - "Sync": "Синхронизация", + "Sync": "Синхро", "System": "Система", "TvShows": "ТВ", "User": "Пользователь", @@ -90,14 +89,14 @@ "UserPolicyUpdatedWithName": "Политики пользователя {0} были обновлены", "UserStartedPlayingItemWithValues": "{0} - воспроизведение «{1}» на {2}", "UserStoppedPlayingItemWithValues": "{0} - воспроизведение остановлено «{1}» на {2}", - "ValueHasBeenAddedToLibrary": "{0} (добавлено в медиатеку)", - "ValueSpecialEpisodeName": "Специальный эпизод - {0}", + "ValueHasBeenAddedToLibrary": "{0} добавлено в медиатеку", + "ValueSpecialEpisodeName": "Спецэпизод - {0}", "VersionNumber": "Версия {0}", "TaskDownloadMissingSubtitles": "Загрузка отсутствующих субтитров", "TaskRefreshChannels": "Обновление каналов", "TaskCleanTranscode": "Очистка каталога перекодировки", "TaskUpdatePlugins": "Обновление плагинов", - "TaskRefreshPeople": "Обновление метаданных людей", + "TaskRefreshPeople": "Подновить людей", "TaskCleanLogs": "Очистка каталога журналов", "TaskRefreshLibrary": "Сканирование медиатеки", "TaskRefreshChapterImages": "Извлечение изображений сцен", @@ -110,9 +109,16 @@ "TaskRefreshChannelsDescription": "Обновляются данные интернет-каналов.", "TaskCleanTranscodeDescription": "Удаляются файлы перекодировки старше одного дня.", "TaskUpdatePluginsDescription": "Загружаются и устанавливаются обновления для плагинов, у которых включено автоматическое обновление.", - "TaskRefreshPeopleDescription": "Обновляются метаданные актеров и режиссёров в медиатеке.", + "TaskRefreshPeopleDescription": "Обновляются метаданные для актёров и режиссёров в медиатеке.", "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).", "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.", "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.", - "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе." + "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.", + "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.", + "TaskCleanActivityLog": "Очистить журнал активности", + "Undefined": "Не определено", + "Forced": "Форсир-ые", + "Default": "По умолчанию", + "TaskOptimizeDatabaseDescription": "Сжимает базу данных и обрезает свободное место. Выполнение этой задачи после сканирования библиотеки или внесения других изменений, предполагающих модификации базы данных, может повысить производительность.", + "TaskOptimizeDatabase": "Оптимизировать базу данных" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 0ee652637..ad90bd813 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -2,7 +2,7 @@ "Albums": "Albumy", "AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}", "Application": "Aplikácia", - "Artists": "Umelci", + "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspešne overený", "Books": "Knihy", "CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia", @@ -15,14 +15,13 @@ "Favorites": "Obľúbené", "Folders": "Priečinky", "Genres": "Žánre", - "HeaderAlbumArtists": "Umelci albumu", - "HeaderCameraUploads": "Nahrané fotografie", + "HeaderAlbumArtists": "Interpreti albumu", "HeaderContinueWatching": "Pokračovať v pozeraní", "HeaderFavoriteAlbums": "Obľúbené albumy", - "HeaderFavoriteArtists": "Obľúbení umelci", + "HeaderFavoriteArtists": "Obľúbení interpreti", "HeaderFavoriteEpisodes": "Obľúbené epizódy", "HeaderFavoriteShows": "Obľúbené seriály", - "HeaderFavoriteSongs": "Obľúbené piesne", + "HeaderFavoriteSongs": "Obľúbené skladby", "HeaderLiveTV": "Živá TV", "HeaderNextUp": "Nasleduje", "HeaderRecordingGroups": "Skupiny nahrávok", @@ -34,7 +33,7 @@ "LabelRunningTimeValue": "Dĺžka: {0}", "Latest": "Najnovšie", "MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný", - "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizový na verziu {0}", + "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizovaný na verziu {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná", "MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná", "MixedContent": "Zmiešaný obsah", @@ -72,7 +71,7 @@ "ScheduledTaskStartedWithName": "{0} zahájených", "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart", "Shows": "Seriály", - "Songs": "Piesne", + "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.", "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo", "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo", @@ -90,29 +89,36 @@ "UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované", "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} bol pridané do vašej knižnice médií", + "ValueHasBeenAddedToLibrary": "{0} bol pridaný do vašej knižnice médií", "ValueSpecialEpisodeName": "Špeciál - {0}", "VersionNumber": "Verzia {0}", "TaskDownloadMissingSubtitlesDescription": "Vyhľadá na internete chýbajúce titulky podľa toho, ako sú nakonfigurované metadáta.", "TaskDownloadMissingSubtitles": "Stiahnuť chýbajúce titulky", "TaskRefreshChannelsDescription": "Obnoví informácie o internetových kanáloch.", "TaskRefreshChannels": "Obnoviť kanály", - "TaskCleanTranscodeDescription": "Vymaže súbory transkódovania, ktoré sú staršie ako jeden deň.", - "TaskCleanTranscode": "Vyčistiť priečinok pre transkódovanie", + "TaskCleanTranscodeDescription": "Vymaže prekódované súbory, ktoré sú staršie ako jeden deň.", + "TaskCleanTranscode": "Vyčistiť priečinok pre prekódovanie", "TaskUpdatePluginsDescription": "Stiahne a nainštaluje aktualizácie pre zásuvné moduly, ktoré sú nastavené tak, aby sa aktualizovali automaticky.", "TaskUpdatePlugins": "Aktualizovať zásuvné moduly", "TaskRefreshPeopleDescription": "Aktualizuje metadáta pre hercov a režisérov vo vašej mediálnej knižnici.", "TaskRefreshPeople": "Obnoviť osoby", - "TaskCleanLogsDescription": "Vymaže log súbory, ktoré su staršie ako {0} deň/dni/dní.", + "TaskCleanLogsDescription": "Vymaže log súbory, ktoré sú staršie ako {0} deň/dni/dní.", "TaskCleanLogs": "Vyčistiť priečinok s logmi", "TaskRefreshLibraryDescription": "Hľadá vo vašej mediálnej knižnici nové súbory a obnovuje metadáta.", "TaskRefreshLibrary": "Prehľadávať knižnicu medií", "TaskRefreshChapterImagesDescription": "Vytvorí náhľady pre videá, ktoré majú kapitoly.", "TaskRefreshChapterImages": "Extrahovať obrázky kapitol", - "TaskCleanCacheDescription": "Vymaže cache súbory, ktoré nie sú už potrebné pre systém.", - "TaskCleanCache": "Vyčistiť Cache priečinok", + "TaskCleanCacheDescription": "Vymaže súbory vyrovnávacej pamäte, ktoré už nie sú potrebné pre systém.", + "TaskCleanCache": "Vyčistiť priečinok vyrovnávacej pamäte", "TasksChannelsCategory": "Internetové kanály", "TasksApplicationCategory": "Aplikácia", "TasksLibraryCategory": "Knižnica", - "TasksMaintenanceCategory": "Údržba" + "TasksMaintenanceCategory": "Údržba", + "TaskCleanActivityLogDescription": "Vymaže záznamy aktivít v logu, ktoré sú staršie ako zadaná doba.", + "TaskCleanActivityLog": "Vyčistiť log aktivít", + "Undefined": "Nedefinované", + "Forced": "Vynútené", + "Default": "Predvolené", + "TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.", + "TaskOptimizeDatabase": "Optimalizovať databázu" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 329c562e7..a6fcbd3e2 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -3,21 +3,20 @@ "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}", "Application": "Aplikacija", "Artists": "Izvajalci", - "AuthenticationSucceededWithUserName": "{0} preverjanje pristnosti uspešno", + "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija je bila naložena z {0}", + "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}", "Channels": "Kanali", "ChapterNameValue": "Poglavje {0}", "Collections": "Zbirke", "DeviceOfflineWithName": "{0} je prekinil povezavo", "DeviceOnlineWithName": "{0} je povezan", - "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave z {0}", + "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave iz {0}", "Favorites": "Priljubljeno", "Folders": "Mape", "Genres": "Zvrsti", "HeaderAlbumArtists": "Izvajalci albuma", - "HeaderCameraUploads": "Posnetki kamere", - "HeaderContinueWatching": "Nadaljuj gledanje", + "HeaderContinueWatching": "Nadaljuj ogled", "HeaderFavoriteAlbums": "Priljubljeni albumi", "HeaderFavoriteArtists": "Priljubljeni izvajalci", "HeaderFavoriteEpisodes": "Priljubljene epizode", @@ -33,23 +32,23 @@ "LabelIpAddressValue": "IP naslov: {0}", "LabelRunningTimeValue": "Čas trajanja: {0}", "Latest": "Najnovejše", - "MessageApplicationUpdated": "Jellyfin Server je bil posodobljen", - "MessageApplicationUpdatedTo": "Jellyfin Server je bil posodobljen na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitve strežnika {0} je bil posodobljen", + "MessageApplicationUpdated": "Jellyfin strežnik je bil posodobljen", + "MessageApplicationUpdatedTo": "Jellyfin strežnik je bil posodobljen na {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Oddelek nastavitev {0} je bil posodobljen", "MessageServerConfigurationUpdated": "Nastavitve strežnika so bile posodobljene", - "MixedContent": "Razne vsebine", + "MixedContent": "Mešane vsebine", "Movies": "Filmi", "Music": "Glasba", "MusicVideos": "Glasbeni videi", "NameInstallFailed": "{0} namestitev neuspešna", "NameSeasonNumber": "Sezona {0}", - "NameSeasonUnknown": "Season neznana", + "NameSeasonUnknown": "Neznana sezona", "NewVersionIsAvailable": "Nova različica Jellyfin strežnika je na voljo za prenos.", "NotificationOptionApplicationUpdateAvailable": "Posodobitev aplikacije je na voljo", "NotificationOptionApplicationUpdateInstalled": "Posodobitev aplikacije je bila nameščena", - "NotificationOptionAudioPlayback": "Predvajanje zvoka začeto", - "NotificationOptionAudioPlaybackStopped": "Predvajanje zvoka zaustavljeno", - "NotificationOptionCameraImageUploaded": "Posnetek kamere naložen", + "NotificationOptionAudioPlayback": "Predvajanje zvoka se je začelo", + "NotificationOptionAudioPlaybackStopped": "Predvajanje zvoka se je ustavilo", + "NotificationOptionCameraImageUploaded": "Fotografija naložena", "NotificationOptionInstallationFailed": "Namestitev neuspešna", "NotificationOptionNewLibraryContent": "Nove vsebine dodane", "NotificationOptionPluginError": "Napaka dodatka", @@ -57,41 +56,41 @@ "NotificationOptionPluginUninstalled": "Dodatek odstranjen", "NotificationOptionPluginUpdateInstalled": "Posodobitev dodatka nameščena", "NotificationOptionServerRestartRequired": "Potreben je ponovni zagon strežnika", - "NotificationOptionTaskFailed": "Razporejena naloga neuspešna", + "NotificationOptionTaskFailed": "Načrtovano opravilo neuspešno", "NotificationOptionUserLockedOut": "Uporabnik zaklenjen", "NotificationOptionVideoPlayback": "Predvajanje videa se je začelo", "NotificationOptionVideoPlaybackStopped": "Predvajanje videa se je ustavilo", "Photos": "Fotografije", "Playlists": "Seznami predvajanja", - "Plugin": "Plugin", + "Plugin": "Dodatek", "PluginInstalledWithName": "{0} je bil nameščen", "PluginUninstalledWithName": "{0} je bil odstranjen", "PluginUpdatedWithName": "{0} je bil posodobljen", - "ProviderValue": "Provider: {0}", + "ProviderValue": "Ponudnik: {0}", "ScheduledTaskFailedWithName": "{0} ni uspelo", "ScheduledTaskStartedWithName": "{0} začeto", "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan", "Shows": "Serije", "Songs": "Pesmi", - "StartupEmbyServerIsLoading": "Jellyfin Server se nalaga. Poskusi ponovno kasneje.", + "StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}", "Sync": "Sinhroniziraj", - "System": "System", + "System": "Sistem", "TvShows": "TV serije", - "User": "User", + "User": "Uporabnik", "UserCreatedWithName": "Uporabnik {0} je bil ustvarjen", "UserDeletedWithName": "Uporabnik {0} je bil izbrisan", "UserDownloadingItemWithValues": "{0} prenaša {1}", "UserLockedOutWithName": "Uporabnik {0} je bil zaklenjen", "UserOfflineFromDevice": "{0} je prekinil povezavo z {1}", - "UserOnlineFromDevice": "{0} je aktiven iz {1}", + "UserOnlineFromDevice": "{0} je aktiven na {1}", "UserPasswordChangedWithName": "Geslo za uporabnika {0} je bilo spremenjeno", "UserPolicyUpdatedWithName": "Pravilnik uporabe je bil posodobljen za uporabnika {0}", "UserStartedPlayingItemWithValues": "{0} predvaja {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} je nehal predvajati {1} na {2}", "ValueHasBeenAddedToLibrary": "{0} je bil dodan vaši knjižnici", - "ValueSpecialEpisodeName": "Poseben - {0}", + "ValueSpecialEpisodeName": "Bonus - {0}", "VersionNumber": "Različica {0}", "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise", "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.", @@ -103,7 +102,7 @@ "TaskRefreshPeopleDescription": "Osveži metapodatke za igralce in režiserje v vaši knjižnici.", "TaskRefreshPeople": "Osveži osebe", "TaskCleanLogsDescription": "Izbriše dnevniške datoteke starejše od {0} dni.", - "TaskCleanLogs": "Počisti mapo dnevnika", + "TaskCleanLogs": "Počisti mapo dnevnikov", "TaskRefreshLibraryDescription": "Preišče vašo knjižnico za nove datoteke in osveži metapodatke.", "TaskRefreshLibrary": "Preišči knjižnico predstavnosti", "TaskRefreshChapterImagesDescription": "Ustvari sličice za poglavja videoposnetkov.", @@ -114,5 +113,12 @@ "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", + "TaskOptimizeDatabaseDescription": "Stisne bazo podatkov in uredi prazen prostor. Zagon tega opravila po iskanju predstavnosti ali drugih spremembah ki vplivajo na bazo podatkov lahko izboljša hitrost delovanja.", + "TaskOptimizeDatabase": "Optimiziraj bazo podatkov" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json new file mode 100644 index 000000000..e36fdc43d --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -0,0 +1,123 @@ +{ + "MessageApplicationUpdatedTo": "Serveri Jellyfin u përditesua në versionin {0}", + "Inherit": "Trashgimi", + "TaskDownloadMissingSubtitlesDescription": "Kërkon në internet për titra që mungojnë bazuar tek konfigurimi i metadata-ve.", + "TaskDownloadMissingSubtitles": "Shkarko titra që mungojnë", + "TaskRefreshChannelsDescription": "Rifreskon informacionin e kanaleve të internetit.", + "TaskRefreshChannels": "Rifresko Kanalet", + "TaskCleanTranscodeDescription": "Fshin skedarët e transkodimit që janë më të vjetër se një ditë.", + "TaskCleanTranscode": "Fshi dosjen e transkodimit", + "TaskUpdatePluginsDescription": "Shkarkon dhe instalon përditësimi për plugin që janë konfiguruar të përditësohen automatikisht.", + "TaskUpdatePlugins": "Përditëso Plugin", + "TaskRefreshPeopleDescription": "Përditëson metadata të aktorëve dhe regjizorëve në librarinë tuaj.", + "TaskRefreshPeople": "Rifresko aktorët", + "TaskCleanLogsDescription": "Fshin skëdarët log që janë më të vjetër se {0} ditë.", + "TaskCleanLogs": "Fshi dosjen Log", + "TaskRefreshLibraryDescription": "Skanon librarinë media për skedarë të rinj dhe rifreskon metadata.", + "TaskRefreshLibrary": "Skano librarinë media", + "TaskRefreshChapterImagesDescription": "Krijon imazh për videot që kanë kapituj.", + "TaskRefreshChapterImages": "Ekstrakto Imazhet e Kapitullit", + "TaskCleanCacheDescription": "Fshi skedarët e cache-s që nuk i duhen më sistemit.", + "TaskCleanCache": "Pastro memorjen cache", + "TasksChannelsCategory": "Kanalet nga interneti", + "TasksApplicationCategory": "Aplikacioni", + "TasksLibraryCategory": "Libraria", + "TasksMaintenanceCategory": "Mirëmbajtje", + "VersionNumber": "Versioni {0}", + "ValueSpecialEpisodeName": "Speciale - {0}", + "ValueHasBeenAddedToLibrary": "{0} u shtua tek libraria juaj", + "UserStoppedPlayingItemWithValues": "{0} mbaroi së shikuari {1} tek {2}", + "UserStartedPlayingItemWithValues": "{0} po shikon {1} tek {2}", + "UserPolicyUpdatedWithName": "Politika e përdoruesit u përditësua për {0}", + "UserPasswordChangedWithName": "Fjalëkalimi u ndryshua për përdoruesin {0}", + "UserOnlineFromDevice": "{0} është në linjë nga {1}", + "UserOfflineFromDevice": "{0} u shkëput nga {1}", + "UserLockedOutWithName": "Përdoruesi {0} u përjashtua", + "UserDownloadingItemWithValues": "{0} po shkarkon {1}", + "UserDeletedWithName": "Përdoruesi {0} u fshi", + "UserCreatedWithName": "Përdoruesi {0} u krijua", + "User": "Përdoruesi", + "TvShows": "Seriale TV", + "System": "Sistemi", + "Sync": "Sinkronizo", + "SubtitleDownloadFailureFromForItem": "Titrat deshtuan të shkarkohen nga {0} për {1}", + "StartupEmbyServerIsLoading": "Serveri Jellyfin po ngarkohet. Ju lutemi provoni përseri pas pak.", + "Songs": "Këngët", + "Shows": "Serialet", + "ServerNameNeedsToBeRestarted": "{0} duhet të ristartoj", + "ScheduledTaskStartedWithName": "{0} filloi", + "ScheduledTaskFailedWithName": "{0} dështoi", + "ProviderValue": "Ofruesi: {0}", + "PluginUpdatedWithName": "{0} u përditësua", + "PluginUninstalledWithName": "{0} u çinstalua", + "PluginInstalledWithName": "{0} u instalua", + "Plugin": "Plugin", + "Playlists": "Listat për luajtje", + "Photos": "Fotografitë", + "NotificationOptionVideoPlaybackStopped": "Luajtja e videos ndaloi", + "NotificationOptionVideoPlayback": "Luajtja e videos filloi", + "NotificationOptionUserLockedOut": "Përdoruesi u përjashtua", + "NotificationOptionTaskFailed": "Ushtrimi i planifikuar dështoi", + "NotificationOptionServerRestartRequired": "Kërkohet ristartim i serverit", + "NotificationOptionPluginUpdateInstalled": "Përditësimi i plugin u instalua", + "NotificationOptionPluginUninstalled": "Plugin u çinstalua", + "NotificationOptionPluginInstalled": "Plugin u instalua", + "NotificationOptionPluginError": "Plugin dështoi", + "NotificationOptionNewLibraryContent": "Një përmbajtje e re u shtua", + "NotificationOptionInstallationFailed": "Instalimi dështoi", + "NotificationOptionCameraImageUploaded": "Fotoja nga kamera u ngarkua", + "NotificationOptionAudioPlaybackStopped": "Luajtja e audios ndaloi", + "NotificationOptionAudioPlayback": "Luajtja e audios filloi", + "NotificationOptionApplicationUpdateInstalled": "Përditësimi i aplikacionit u instalua", + "NotificationOptionApplicationUpdateAvailable": "Një perditësim i aplikacionit është gati", + "NewVersionIsAvailable": "Një version i ri i Jellyfin është gati për tu shkarkuar.", + "NameSeasonUnknown": "Sezon i panjohur", + "NameSeasonNumber": "Sezoni {0}", + "NameInstallFailed": "Instalimi i {0} dështoi", + "MusicVideos": "Videot muzikore", + "Music": "Muzikë", + "Movies": "Filmat", + "MixedContent": "Përmbajtje e përzier", + "MessageServerConfigurationUpdated": "Konfigurimet e serverit u përditësuan", + "MessageNamedServerConfigurationUpdatedWithValue": "Seksioni i konfigurimit të serverit {0} u përditësua", + "MessageApplicationUpdated": "Serveri Jellyfin u përditësua", + "Latest": "Të fundit", + "LabelRunningTimeValue": "Kohëzgjatja: {0}", + "LabelIpAddressValue": "Adresa IP: {0}", + "ItemRemovedWithName": "{0} u fshi nga libraria", + "ItemAddedWithName": "{0} u shtua tek libraria", + "HomeVideos": "Video personale", + "HeaderRecordingGroups": "Grupet e regjistrimit", + "HeaderNextUp": "Në vazhdim", + "HeaderLiveTV": "TV Live", + "HeaderFavoriteSongs": "Kënget e preferuara", + "HeaderFavoriteShows": "Serialet e preferuar", + "HeaderFavoriteEpisodes": "Episodet e preferuar", + "HeaderFavoriteArtists": "Artistët e preferuar", + "HeaderFavoriteAlbums": "Albumet e preferuar", + "HeaderContinueWatching": "Vazhdo të shikosh", + "HeaderAlbumArtists": "Artistët e albumeve", + "Genres": "Zhanret", + "Folders": "Skedarët", + "Favorites": "Të preferuarat", + "FailedLoginAttemptWithUserName": "Përpjekja për hyrje dështoi nga {0}", + "DeviceOnlineWithName": "{0} u lidh", + "DeviceOfflineWithName": "{0} u shkëput", + "Collections": "Koleksionet", + "ChapterNameValue": "Kapituj", + "Channels": "Kanalet", + "CameraImageUploadedFrom": "Një foto e re nga kamera u ngarkua nga {0}", + "Books": "Librat", + "AuthenticationSucceededWithUserName": "{0} u identifikua me sukses", + "Artists": "Artistët", + "Application": "Aplikacioni", + "AppDeviceValues": "Aplikacioni: {0}, Pajisja: {1}", + "Albums": "Albumet", + "TaskCleanActivityLogDescription": "Pastro të dhënat mbi aktivitetin më të vjetra sesa koha e përcaktuar.", + "TaskCleanActivityLog": "Pastro të dhënat mbi aktivitetin", + "Undefined": "I papërcaktuar", + "Forced": "I detyruar", + "Default": "Parazgjedhur", + "TaskOptimizeDatabaseDescription": "Kompakton bazën e të dhënave dhe shkurton hapësirën e lirë. Drejtimi i kësaj detyre pasi skanoni bibliotekën ose bëni ndryshime të tjera që nënkuptojnë modifikime të bazës së të dhënave mund të përmirësojë performancën.", + "TaskOptimizeDatabase": "Optimizo databazën" +} diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 5f3cbb1c8..15fb34186 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -4,11 +4,11 @@ "VersionNumber": "Верзија {0}", "ValueSpecialEpisodeName": "Специјал - {0}", "ValueHasBeenAddedToLibrary": "{0} је додато у вашу медијску библиотеку", - "UserStoppedPlayingItemWithValues": "{0} заврши пуштање {1} на {2}", + "UserStoppedPlayingItemWithValues": "{0} завршио пуштање {1} на {2}", "UserStartedPlayingItemWithValues": "{0} пушта {1} на {2}", "UserPasswordChangedWithName": "Лозинка је промењена за корисника {0}", "UserOnlineFromDevice": "{0} је на вези од {1}", - "UserOfflineFromDevice": "{0} се одвезао са {1}", + "UserOfflineFromDevice": "{0} је прекинуо/а везу са {1}", "UserLockedOutWithName": "Корисник {0} је закључан", "UserDownloadingItemWithValues": "{0} преузима {1}", "UserDeletedWithName": "Корисник {0} је обрисан", @@ -41,7 +41,7 @@ "NotificationOptionPluginError": "Грешка прикључка", "NotificationOptionNewLibraryContent": "Додат нови садржај", "NotificationOptionInstallationFailed": "Неуспела инсталација", - "NotificationOptionCameraImageUploaded": "Слика са камере послата", + "NotificationOptionCameraImageUploaded": "Слика са камере отпремљена", "NotificationOptionAudioPlaybackStopped": "Заустављено пуштање звука", "NotificationOptionAudioPlayback": "Покренуто пуштање звука", "NotificationOptionApplicationUpdateInstalled": "Ажурирање инсталирано", @@ -66,7 +66,7 @@ "Inherit": "Наследи", "HomeVideos": "Кућни видео", "HeaderRecordingGroups": "Групе снимања", - "HeaderNextUp": "Следеће горе", + "HeaderNextUp": "Следи", "HeaderLiveTV": "ТВ уживо", "HeaderFavoriteSongs": "Омиљене песме", "HeaderFavoriteShows": "Омиљене серије", @@ -74,23 +74,22 @@ "HeaderFavoriteArtists": "Омиљени извођачи", "HeaderFavoriteAlbums": "Омиљени албуми", "HeaderContinueWatching": "Настави гледање", - "HeaderCameraUploads": "Слања са камере", "HeaderAlbumArtists": "Извођачи албума", "Genres": "Жанрови", "Folders": "Фасцикле", "Favorites": "Омиљено", "FailedLoginAttemptWithUserName": "Неуспела пријава са {0}", - "DeviceOnlineWithName": "{0} се повезао", + "DeviceOnlineWithName": "{0} је повезан", "DeviceOfflineWithName": "{0} је прекинуо везу", "Collections": "Колекције", "ChapterNameValue": "Поглавље {0}", "Channels": "Канали", - "CameraImageUploadedFrom": "Нова фотографија је послата са {0}", + "CameraImageUploadedFrom": "Нова фотографија је учитана са {0}", "Books": "Књиге", - "AuthenticationSucceededWithUserName": "{0} успешно проверено", - "Artists": "Извођач", + "AuthenticationSucceededWithUserName": "{0} Успешна аутентикација", + "Artists": "Извођачи", "Application": "Апликација", - "AppDeviceValues": "Апл: {0}, уређај: {1}", + "AppDeviceValues": "Апликација: {0}, Уређај: {1}", "Albums": "Албуми", "TaskDownloadMissingSubtitlesDescription": "Претражује интернет за недостајуће титлове на основу конфигурације метаподатака.", "TaskDownloadMissingSubtitles": "Преузмите недостајуће титлове", @@ -101,11 +100,11 @@ "TaskUpdatePluginsDescription": "Преузима и инсталира исправке за додатке који су конфигурисани за аутоматско ажурирање.", "TaskUpdatePlugins": "Ажурирајте додатке", "TaskRefreshPeopleDescription": "Ажурира метаподатке за глумце и редитеље у вашој медијској библиотеци.", - "TaskRefreshPeople": "Освежите људе", + "TaskRefreshPeople": "Освежите кориснике", "TaskCleanLogsDescription": "Брише логове старије од {0} дана.", "TaskCleanLogs": "Очистите директоријум логова", "TaskRefreshLibraryDescription": "Скенира вашу медијску библиотеку за нове датотеке и освежава метаподатке.", - "TaskRefreshLibrary": "Скенирај Библиотеку Медија", + "TaskRefreshLibrary": "Скенирај библиотеку медија", "TaskRefreshChapterImagesDescription": "Ствара сличице за видео записе који имају поглавља.", "TaskRefreshChapterImages": "Издвоји слике из поглавља", "TaskCleanCacheDescription": "Брише Кеш фајлове који више нису потребни систему.", @@ -113,5 +112,10 @@ "TasksChannelsCategory": "Интернет канали", "TasksApplicationCategory": "Апликација", "TasksLibraryCategory": "Библиотека", - "TasksMaintenanceCategory": "Одржавање" + "TasksMaintenanceCategory": "Одржавање", + "TaskCleanActivityLogDescription": "Брише историју активности старију од конфигурисаног броја година.", + "TaskCleanActivityLog": "Очисти историју активности", + "Undefined": "Недефинисано", + "Forced": "Принудно", + "Default": "Подразумевано" } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index c8662b2ca..88b182f8d 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -9,14 +9,13 @@ "Channels": "Kanaler", "ChapterNameValue": "Kapitel {0}", "Collections": "Samlingar", - "DeviceOfflineWithName": "{0} har kopplat från", + "DeviceOfflineWithName": "{0} har kopplat ner", "DeviceOnlineWithName": "{0} är ansluten", "FailedLoginAttemptWithUserName": "Misslyckat inloggningsförsök från {0}", "Favorites": "Favoriter", "Folders": "Mappar", "Genres": "Genrer", "HeaderAlbumArtists": "Albumartister", - "HeaderCameraUploads": "Kamerauppladdningar", "HeaderContinueWatching": "Fortsätt kolla", "HeaderFavoriteAlbums": "Favoritalbum", "HeaderFavoriteArtists": "Favoritartister", @@ -40,7 +39,7 @@ "MixedContent": "Blandat innehåll", "Movies": "Filmer", "Music": "Musik", - "MusicVideos": "Musikvideos", + "MusicVideos": "Musikvideor", "NameInstallFailed": "{0} installationen misslyckades", "NameSeasonNumber": "Säsong {0}", "NameSeasonUnknown": "Okänd säsong", @@ -114,5 +113,11 @@ "TasksApplicationCategory": "Applikation", "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Underhåll", - "TaskRefreshPeople": "Uppdatera Personer" + "TaskRefreshPeople": "Uppdatera Personer", + "TaskCleanActivityLogDescription": "Radera aktivitets logg inlägg som är äldre än definerad ålder.", + "TaskCleanActivityLog": "Rensa Aktivitets Logg", + "Undefined": "odefinierad", + "Forced": "Tvingad", + "Default": "Standard", + "TaskOptimizeDatabase": "Optimera databasen" } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index d6be86da3..d6e9aa8e5 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -18,20 +18,19 @@ "MessageServerConfigurationUpdated": "சேவையக அமைப்புகள் புதுப்பிக்கப்பட்டன", "MessageApplicationUpdatedTo": "ஜெல்லிஃபின் சேவையகம் {0} இற்கு புதுப்பிக்கப்பட்டது", "MessageApplicationUpdated": "ஜெல்லிஃபின் சேவையகம் புதுப்பிக்கப்பட்டது", - "Inherit": "மரபரிமையாகப் பெறு", + "Inherit": "மரபுரிமையாகப் பெறு", "HeaderRecordingGroups": "பதிவு குழுக்கள்", - "HeaderCameraUploads": "புகைப்பட பதிவேற்றங்கள்", "Folders": "கோப்புறைகள்", - "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", + "FailedLoginAttemptWithUserName": "{0} இல் இருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது", "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது", "Collections": "தொகுப்புகள்", - "CameraImageUploadedFrom": "{0} இலிருந்து புதிய புகைப்படம் பதிவேற்றப்பட்டது", + "CameraImageUploadedFrom": "{0} இல் இருந்து புதிய புகைப்படம் பதிவேற்றப்பட்டது", "AppDeviceValues": "செயலி: {0}, சாதனம்: {1}", "TaskDownloadMissingSubtitles": "விடுபட்டுபோன வசன வரிகளைப் பதிவிறக்கு", "TaskRefreshChannels": "சேனல்களை புதுப்பி", "TaskUpdatePlugins": "உட்செருகிகளை புதுப்பி", - "TaskRefreshLibrary": "மீடியா நூலகத்தை ஆராய்", + "TaskRefreshLibrary": "ஊடக நூலகத்தை ஆராய்", "TasksChannelsCategory": "இணைய சேனல்கள்", "TasksApplicationCategory": "செயலி", "TasksLibraryCategory": "நூலகம்", @@ -46,7 +45,7 @@ "Sync": "ஒத்திசைவு", "StartupEmbyServerIsLoading": "ஜெல்லிஃபின் சேவையகம் துவங்குகிறது. சிறிது நேரம் கழித்து முயற்சிக்கவும்.", "Songs": "பாடல்கள்", - "Shows": "தொடர்கள்", + "Shows": "நிகழ்ச்சிகள்", "ServerNameNeedsToBeRestarted": "{0} மறுதொடக்கம் செய்யப்பட வேண்டும்", "ScheduledTaskStartedWithName": "{0} துவங்கியது", "ScheduledTaskFailedWithName": "{0} தோல்வியடைந்தது", @@ -67,20 +66,20 @@ "NotificationOptionAudioPlayback": "ஒலி இசைக்கத் துவங்கியுள்ளது", "NotificationOptionApplicationUpdateInstalled": "செயலி புதுப்பிக்கப்பட்டது", "NotificationOptionApplicationUpdateAvailable": "செயலியினை புதுப்பிக்கலாம்", - "NameSeasonUnknown": "பருவம் அறியப்படாதவை", + "NameSeasonUnknown": "அறியப்படாத பருவம்", "NameSeasonNumber": "பருவம் {0}", "NameInstallFailed": "{0} நிறுவல் தோல்வியடைந்தது", - "MusicVideos": "இசைப்படங்கள்", + "MusicVideos": "இசை கானொளி", "Music": "இசை", "Movies": "திரைப்படங்கள்", - "Latest": "புதியன", + "Latest": "புதியவை", "LabelRunningTimeValue": "ஓடும் நேரம்: {0}", "LabelIpAddressValue": "ஐபி முகவரி: {0}", "ItemRemovedWithName": "{0} நூலகத்திலிருந்து அகற்றப்பட்டது", "ItemAddedWithName": "{0} நூலகத்தில் சேர்க்கப்பட்டது", - "HeaderNextUp": "அடுத்ததாக", + "HeaderNextUp": "அடுத்தது", "HeaderLiveTV": "நேரடித் தொலைக்காட்சி", - "HeaderFavoriteSongs": "பிடித்த பாட்டுகள்", + "HeaderFavoriteSongs": "பிடித்த பாடல்கள்", "HeaderFavoriteShows": "பிடித்த தொடர்கள்", "HeaderFavoriteEpisodes": "பிடித்த அத்தியாயங்கள்", "HeaderFavoriteArtists": "பிடித்த கலைஞர்கள்", @@ -93,25 +92,32 @@ "Channels": "சேனல்கள்", "Books": "புத்தகங்கள்", "AuthenticationSucceededWithUserName": "{0} வெற்றிகரமாக அங்கீகரிக்கப்பட்டது", - "Artists": "கலைஞர்", + "Artists": "கலைஞர்கள்", "Application": "செயலி", "Albums": "ஆல்பங்கள்", "NewVersionIsAvailable": "ஜெல்லிஃபின் சேவையகத்தின் புதிய பதிப்பு பதிவிறக்கத்திற்கு கிடைக்கிறது.", - "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0 புதுப்பிக்கப்பட்டது", + "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது", "TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.", "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது", - "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0 } இலிருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", - "TaskDownloadMissingSubtitlesDescription": "மெட்டாடேட்டா உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.", - "TaskCleanTranscodeDescription": "டிரான்ஸ்கோட் கோப்புகளை ஒரு நாளுக்கு மேல் பழையதாக நீக்குகிறது.", - "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட செருகுநிரல்களுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.", - "TaskRefreshPeopleDescription": "உங்கள் மீடியா நூலகத்தில் உள்ள நடிகர்கள் மற்றும் இயக்குனர்களுக்கான மெட்டாடேட்டாவை புதுப்பிக்கும்.", + "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", + "TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.", + "TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.", + "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.", + "TaskRefreshPeopleDescription": "உங்கள் ஊடக நூலகத்தில் உள்ள நடிகர்கள் மற்றும் இயக்குனர்களுக்கான மீத்தரவை புதுப்பிக்கும்.", "TaskCleanLogsDescription": "{0} நாட்களுக்கு மேல் இருக்கும் பதிவு கோப்புகளை நீக்கும்.", - "TaskCleanLogs": "பதிவு அடைவு சுத்தம் செய்யுங்கள்", - "TaskRefreshLibraryDescription": "புதிய கோப்புகளுக்காக உங்கள் மீடியா நூலகத்தை ஸ்கேன் செய்து மீத்தரவை புதுப்பிக்கும்.", + "TaskCleanLogs": "பதிவு அடைவை சுத்தம் செய்யுங்கள்", + "TaskRefreshLibraryDescription": "புதிய கோப்புகளுக்காக உங்கள் ஊடக நூலகத்தை ஆராய்ந்து மீத்தரவை புதுப்பிக்கும்.", "TaskRefreshChapterImagesDescription": "அத்தியாயங்களைக் கொண்ட வீடியோக்களுக்கான சிறு உருவங்களை உருவாக்குகிறது.", "ValueHasBeenAddedToLibrary": "உங்கள் மீடியா நூலகத்தில் {0} சேர்க்கப்பட்டது", "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்", "HomeVideos": "முகப்பு வீடியோக்கள்", - "UserStoppedPlayingItemWithValues": "{2} இல் {1} முடித்துவிட்டது", - "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது" + "UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது", + "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது", + "TaskCleanActivityLogDescription": "உள்ளமைக்கப்பட்ட வயதை விட பழைய செயல்பாட்டு பதிவு உள்ளீடுகளை நீக்குகிறது.", + "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி", + "Undefined": "வரையறுக்கப்படாத", + "Forced": "கட்டாயப்படுத்தப்பட்டது", + "Default": "இயல்புநிலை", + "TaskOptimizeDatabaseDescription": "தரவுத்தளத்தை சுருக்கி, இலவச இடத்தை குறைக்கிறது. நூலகத்தை ஸ்கேன் செய்தபின் அல்லது தரவுத்தள மாற்றங்களைக் குறிக்கும் பிற மாற்றங்களைச் செய்தபின் இந்த பணியை இயக்குவது செயல்திறனை மேம்படுத்தக்கூடும்.", + "TaskOptimizeDatabase": "தரவுத்தளத்தை மேம்படுத்தவும்" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 42500670d..e26010423 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -1,76 +1,121 @@ { "ProviderValue": "ผู้ให้บริการ: {0}", - "PluginUpdatedWithName": "{0} ได้รับการ update แล้ว", - "PluginUninstalledWithName": "ถอนการติดตั้ง {0}", - "PluginInstalledWithName": "{0} ได้รับการติดตั้ง", - "Plugin": "Plugin", - "Playlists": "รายการ", + "PluginUpdatedWithName": "อัปเดต {0} แล้ว", + "PluginUninstalledWithName": "ถอนการติดตั้ง {0} แล้ว", + "PluginInstalledWithName": "ติดตั้ง {0} แล้ว", + "Plugin": "ปลั๊กอิน", + "Playlists": "เพลย์ลิสต์", "Photos": "รูปภาพ", - "NotificationOptionVideoPlaybackStopped": "หยุดการเล่น Video", - "NotificationOptionVideoPlayback": "เริ่มแสดง Video", - "NotificationOptionUserLockedOut": "ผู้ใช้ Locked Out", - "NotificationOptionTaskFailed": "ตารางการทำงานล้มเหลว", - "NotificationOptionServerRestartRequired": "ควร Restart Server", - "NotificationOptionPluginUpdateInstalled": "Update Plugin แล้ว", - "NotificationOptionPluginUninstalled": "ถอด Plugin", - "NotificationOptionPluginInstalled": "ติดตั้ง Plugin แล้ว", - "NotificationOptionPluginError": "Plugin ล้มเหลว", - "NotificationOptionNewLibraryContent": "เพิ่มข้อมูลใหม่แล้ว", - "NotificationOptionInstallationFailed": "ติดตั้งล้มเหลว", - "NotificationOptionCameraImageUploaded": "รูปภาพถูก upload", - "NotificationOptionAudioPlaybackStopped": "หยุดการเล่นเสียง", + "NotificationOptionVideoPlaybackStopped": "หยุดเล่นวิดีโอ", + "NotificationOptionVideoPlayback": "เริ่มเล่นวิดีโอ", + "NotificationOptionUserLockedOut": "ผู้ใช้ถูกล็อก", + "NotificationOptionTaskFailed": "งานตามกำหนดการล้มเหลว", + "NotificationOptionServerRestartRequired": "จำเป็นต้องรีสตาร์ทเซิร์ฟเวอร์", + "NotificationOptionPluginUpdateInstalled": "ติดตั้งการอัปเดตปลั๊กอินแล้ว", + "NotificationOptionPluginUninstalled": "ถอนการติดตั้งปลั๊กอินแล้ว", + "NotificationOptionPluginInstalled": "ติดตั้งปลั๊กอินแล้ว", + "NotificationOptionPluginError": "ปลั๊กอินล้มเหลว", + "NotificationOptionNewLibraryContent": "เพิ่มเนื้อหาใหม่แล้ว", + "NotificationOptionInstallationFailed": "การติดตั้งล้มเหลว", + "NotificationOptionCameraImageUploaded": "อัปโหลดภาพถ่ายแล้ว", + "NotificationOptionAudioPlaybackStopped": "หยุดเล่นเสียง", "NotificationOptionAudioPlayback": "เริ่มเล่นเสียง", - "NotificationOptionApplicationUpdateInstalled": "Update ระบบแล้ว", - "NotificationOptionApplicationUpdateAvailable": "ระบบ update สามารถใช้ได้แล้ว", - "NewVersionIsAvailable": "ตรวจพบ Jellyfin เวอร์ชั่นใหม่", - "NameSeasonUnknown": "ไม่ทราบปี", - "NameSeasonNumber": "ปี {0}", - "NameInstallFailed": "{0} ติดตั้งไม่สำเร็จ", - "MusicVideos": "MV", - "Music": "เพลง", - "Movies": "ภาพยนต์", - "MixedContent": "รายการแบบผสม", - "MessageServerConfigurationUpdated": "การตั้งค่า update แล้ว", - "MessageNamedServerConfigurationUpdatedWithValue": "รายการตั้งค่า {0} ได้รับการ update แล้ว", - "MessageApplicationUpdatedTo": "Jellyfin Server จะ update ไปที่ {0}", - "MessageApplicationUpdated": "Jellyfin Server update แล้ว", + "NotificationOptionApplicationUpdateInstalled": "ติดตั้งการอัปเดตแอปพลิเคชันแล้ว", + "NotificationOptionApplicationUpdateAvailable": "มีการอัปเดตแอปพลิเคชัน", + "NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว", + "NameSeasonUnknown": "ไม่ทราบซีซัน", + "NameSeasonNumber": "ซีซัน {0}", + "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว", + "MusicVideos": "มิวสิควิดีโอ", + "Music": "ดนตรี", + "Movies": "ภาพยนตร์", + "MixedContent": "เนื้อหาผสม", + "MessageServerConfigurationUpdated": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์แล้ว", + "MessageNamedServerConfigurationUpdatedWithValue": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์ในส่วน {0} แล้ว", + "MessageApplicationUpdatedTo": "เซิร์ฟเวอร์ Jellyfin ได้รับการอัปเดตเป็น {0}", + "MessageApplicationUpdated": "อัพเดตเซิร์ฟเวอร์ Jellyfin แล้ว", "Latest": "ล่าสุด", - "LabelRunningTimeValue": "เวลาที่เล่น : {0}", - "LabelIpAddressValue": "IP address: {0}", - "ItemRemovedWithName": "{0} ถูกลบจากรายการ", - "ItemAddedWithName": "{0} ถูกเพิ่มในรายการ", - "Inherit": "การสืบทอด", - "HomeVideos": "วีดีโอส่วนตัว", - "HeaderRecordingGroups": "ค่ายบันทึก", + "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}", + "LabelIpAddressValue": "ที่อยู่ IP: {0}", + "ItemRemovedWithName": "{0} ถูกลบออกจากไลบรารี", + "ItemAddedWithName": "{0} ถูกเพิ่มลงในไลบรารีแล้ว", + "Inherit": "สืบทอด", + "HomeVideos": "โฮมวิดีโอ", + "HeaderRecordingGroups": "กลุ่มการบันทึก", "HeaderNextUp": "ถัดไป", - "HeaderLiveTV": "รายการสด", - "HeaderFavoriteSongs": "เพลงโปรด", - "HeaderFavoriteShows": "รายการโชว์โปรด", - "HeaderFavoriteEpisodes": "ฉากโปรด", - "HeaderFavoriteArtists": "นักแสดงโปรด", - "HeaderFavoriteAlbums": "อัมบั้มโปรด", - "HeaderContinueWatching": "ชมต่อจากเดิม", - "HeaderCameraUploads": "Upload รูปภาพ", - "HeaderAlbumArtists": "อัลบั้มนักแสดง", + "HeaderLiveTV": "ทีวีสด", + "HeaderFavoriteSongs": "เพลงที่ชื่นชอบ", + "HeaderFavoriteShows": "รายการที่ชื่นชอบ", + "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ", + "HeaderFavoriteArtists": "ศิลปินที่ชื่นชอบ", + "HeaderFavoriteAlbums": "อัมบั้มที่ชื่นชอบ", + "HeaderContinueWatching": "ดูต่อ", + "HeaderAlbumArtists": "ศิลปินอัลบั้ม", "Genres": "ประเภท", "Folders": "โฟลเดอร์", "Favorites": "รายการโปรด", - "FailedLoginAttemptWithUserName": "การเชื่อมต่อล้มเหลวจาก {0}", - "DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จ", - "DeviceOfflineWithName": "{0} ตัดการเชื่อมต่อ", - "Collections": "ชุด", - "ChapterNameValue": "บทที่ {0}", - "Channels": "ชาแนล", - "CameraImageUploadedFrom": "รูปภาพถูก upload จาก {0}", + "FailedLoginAttemptWithUserName": "ความพยายามในการเข้าสู่ระบบล้มเหลวจาก {0}", + "DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว", + "DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว", + "Collections": "คอลเลกชัน", + "ChapterNameValue": "บท {0}", + "Channels": "ช่อง", + "CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}", "Books": "หนังสือ", - "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จ", - "Artists": "นักแสดง", - "Application": "แอปพลิเคชั่น", - "AppDeviceValues": "App: {0}, อุปกรณ์: {1}", + "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จแล้ว", + "Artists": "ศิลปิน", + "Application": "แอปพลิเคชัน", + "AppDeviceValues": "แอป: {0}, อุปกรณ์: {1}", "Albums": "อัลบั้ม", "ScheduledTaskStartedWithName": "{0} เริ่มต้น", "ScheduledTaskFailedWithName": "{0} ล้มเหลว", "Songs": "เพลง", - "Shows": "แสดง", - "ServerNameNeedsToBeRestarted": "{0} ต้องการรีสตาร์ท" + "Shows": "รายการ", + "ServerNameNeedsToBeRestarted": "{0} ต้องการการรีสตาร์ท", + "TaskDownloadMissingSubtitlesDescription": "ค้นหาคำบรรยายที่หายไปในอินเทอร์เน็ตตามค่ากำหนดในข้อมูลเมตา", + "TaskDownloadMissingSubtitles": "ดาวน์โหลดคำบรรยายที่ขาดหายไป", + "TaskRefreshChannelsDescription": "รีเฟรชข้อมูลช่องอินเทอร์เน็ต", + "TaskRefreshChannels": "รีเฟรชช่อง", + "TaskCleanTranscodeDescription": "ลบไฟล์ทรานส์โค้ดที่มีอายุมากกว่าหนึ่งวัน", + "TaskCleanTranscode": "ล้างไดเรกทอรีทรานส์โค้ด", + "TaskUpdatePluginsDescription": "ดาวน์โหลดและติดตั้งโปรแกรมปรับปรุงให้กับปลั๊กอินที่กำหนดค่าให้อัปเดตโดยอัตโนมัติ", + "TaskUpdatePlugins": "อัปเดตปลั๊กอิน", + "TaskRefreshPeopleDescription": "อัปเดตข้อมูลเมตานักแสดงและผู้กำกับในไลบรารีสื่อ", + "TaskRefreshPeople": "รีเฟรชบุคคล", + "TaskCleanLogsDescription": "ลบไฟล์บันทึกที่เก่ากว่า {0} วัน", + "TaskCleanLogs": "ล้างไดเรกทอรีบันทึก", + "TaskRefreshLibraryDescription": "สแกนไลบรารีสื่อของคุณเพื่อหาไฟล์ใหม่และรีเฟรชข้อมูลเมตา", + "TaskRefreshLibrary": "สแกนไลบรารีสื่อ", + "TaskRefreshChapterImagesDescription": "สร้างภาพขนาดย่อสำหรับวิดีโอที่มีบท", + "TaskRefreshChapterImages": "แตกรูปภาพบท", + "TaskCleanCacheDescription": "ลบไฟล์แคชที่ระบบไม่ต้องการ", + "TaskCleanCache": "ล้างไดเรกทอรีแคช", + "TasksChannelsCategory": "ช่องอินเทอร์เน็ต", + "TasksApplicationCategory": "แอปพลิเคชัน", + "TasksLibraryCategory": "ไลบรารี", + "TasksMaintenanceCategory": "ปิดซ่อมบำรุง", + "VersionNumber": "เวอร์ชัน {0}", + "ValueSpecialEpisodeName": "พิเศษ - {0}", + "ValueHasBeenAddedToLibrary": "เพิ่ม {0} ลงในไลบรารีสื่อของคุณแล้ว", + "UserStoppedPlayingItemWithValues": "{0} เล่นเสร็จแล้ว {1} บน {2}", + "UserStartedPlayingItemWithValues": "{0} กำลังเล่น {1} บน {2}", + "UserPolicyUpdatedWithName": "มีการอัปเดตนโยบายผู้ใช้ของ {0}", + "UserPasswordChangedWithName": "มีการเปลี่ยนรหัสผ่านของผู้ใช้ {0}", + "UserOnlineFromDevice": "{0} ออนไลน์จาก {1}", + "UserOfflineFromDevice": "{0} ได้ยกเลิกการเชื่อมต่อจาก {1}", + "UserLockedOutWithName": "ผู้ใช้ {0} ถูกล็อก", + "UserDownloadingItemWithValues": "{0} กำลังดาวน์โหลด {1}", + "UserDeletedWithName": "ลบผู้ใช้ {0} แล้ว", + "UserCreatedWithName": "สร้างผู้ใช้ {0} แล้ว", + "User": "ผู้ใช้งาน", + "TvShows": "รายการทีวี", + "System": "ระบบ", + "Sync": "ซิงค์", + "SubtitleDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดคำบรรยายจาก {0} สำหรับ {1} ได้", + "StartupEmbyServerIsLoading": "กำลังโหลดเซิร์ฟเวอร์ Jellyfin โปรดลองอีกครั้งในอีกสักครู่", + "Default": "ค่าเริ่มต้น", + "TaskCleanActivityLogDescription": "ลบบันทึกกิจกรรมที่เก่ากว่าค่าที่กำหนดไว้", + "TaskCleanActivityLog": "ล้างบันทึกกิจกรรม", + "Undefined": "ไม่ได้กำหนด", + "Forced": "บังคับใช้" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 3cf3482eb..771c91d59 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", "Channels": "Kanallar", "ChapterNameValue": "Bölüm {0}", - "Collections": "Koleksiyonlar", + "Collections": "Koleksiyon", "DeviceOfflineWithName": "{0} bağlantısı kesildi", "DeviceOnlineWithName": "{0} bağlı", "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu", @@ -16,7 +16,6 @@ "Folders": "Klasörler", "Genres": "Türler", "HeaderAlbumArtists": "Albüm Sanatçıları", - "HeaderCameraUploads": "Kamera Yüklemeleri", "HeaderContinueWatching": "İzlemeye Devam Et", "HeaderFavoriteAlbums": "Favori Albümler", "HeaderFavoriteArtists": "Favori Sanatçılar", @@ -24,7 +23,7 @@ "HeaderFavoriteShows": "Favori Diziler", "HeaderFavoriteSongs": "Favori Şarkılar", "HeaderLiveTV": "Canlı TV", - "HeaderNextUp": "Sonraki hafta", + "HeaderNextUp": "Gelecek Hafta", "HeaderRecordingGroups": "Kayıt Grupları", "HomeVideos": "Ev videoları", "Inherit": "Devral", @@ -44,7 +43,7 @@ "NameInstallFailed": "{0} kurulumu başarısız", "NameSeasonNumber": "Sezon {0}", "NameSeasonUnknown": "Bilinmeyen Sezon", - "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir versiyonu indirmek için hazır.", + "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.", "NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut", "NotificationOptionApplicationUpdateInstalled": "Uygulama güncellemesi yüklendi", "NotificationOptionAudioPlayback": "Ses çalma başladı", @@ -76,7 +75,7 @@ "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} 'dan indirilemedi", - "Sync": "Eşitle", + "Sync": "Eşzamanlama", "System": "Sistem", "TvShows": "Diziler", "User": "Kullanıcı", @@ -90,29 +89,36 @@ "UserPolicyUpdatedWithName": "Kullanıcı politikası {0} için güncellendi", "UserStartedPlayingItemWithValues": "{0}, {2} cihazında {1} izliyor", "UserStoppedPlayingItemWithValues": "{0}, {2} cihazında {1} izlemeyi bitirdi", - "ValueHasBeenAddedToLibrary": "Medya kitaplığınıza {0} eklendi", + "ValueHasBeenAddedToLibrary": "Medya kütüphanenize {0} eklendi", "ValueSpecialEpisodeName": "Özel - {0}", - "VersionNumber": "Versiyon {0}", + "VersionNumber": "Sürüm {0}", "TaskCleanCache": "Geçici dosya klasörünü temizle", "TasksChannelsCategory": "İnternet kanalları", "TasksApplicationCategory": "Uygulama", "TasksLibraryCategory": "Kütüphane", - "TasksMaintenanceCategory": "Onarım", + "TasksMaintenanceCategory": "Bakım", "TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.", "TaskDownloadMissingSubtitlesDescription": "Metadata ayarlarını baz alarak eksik altyazıları internette arar.", "TaskDownloadMissingSubtitles": "Eksik altyazıları indir", "TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.", "TaskRefreshChannels": "Kanalları Yenile", - "TaskCleanTranscodeDescription": "Bir günü dolmuş dönüştürme bilgisi içeren dosyaları siler.", + "TaskCleanTranscodeDescription": "Bir günden daha eski dönüştürme dosyalarını siler.", "TaskCleanTranscode": "Dönüşüm Dizinini Temizle", "TaskUpdatePluginsDescription": "Otomatik güncellenmeye ayarlanmış eklentilerin güncellemelerini indirir ve kurar.", "TaskUpdatePlugins": "Eklentileri Güncelle", "TaskRefreshPeople": "Kullanıcıları Yenile", - "TaskCleanLogsDescription": "{0} günden eski log dosyalarını siler.", - "TaskCleanLogs": "Log Dizinini Temizle", - "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve bilgileri yeniler.", + "TaskCleanLogsDescription": "{0} günden eski günlük dosyalarını siler.", + "TaskCleanLogs": "Günlük Dizinini Temizle", + "TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.", "TaskRefreshLibrary": "Medya Kütüphanesini Tara", "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.", "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", - "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler." + "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", + "TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle", + "TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.", + "Undefined": "Bilinmeyen", + "Default": "Varsayılan", + "Forced": "Zorla", + "TaskOptimizeDatabaseDescription": "Veritabanını sıkıştırır ve boş alanı keser. Kitaplığı taradıktan sonra veya veritabanında değişiklik anlamına gelen diğer işlemleri yaptıktan sonra bu görevi çalıştırmak performansı artırabilir.", + "TaskOptimizeDatabase": "Veritabanını optimize et" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index e673465a4..5a2069df5 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -1,5 +1,5 @@ { - "MusicVideos": "Музичні кліпи", + "MusicVideos": "Музичні відеокліпи", "Music": "Музика", "Movies": "Фільми", "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}", @@ -16,7 +16,6 @@ "HeaderFavoriteArtists": "Улюблені виконавці", "HeaderFavoriteAlbums": "Улюблені альбоми", "HeaderContinueWatching": "Продовжити перегляд", - "HeaderCameraUploads": "Завантажено з камери", "HeaderAlbumArtists": "Виконавці альбому", "Genres": "Жанри", "Folders": "Каталоги", @@ -28,7 +27,7 @@ "Channels": "Канали", "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}", "Books": "Книги", - "AuthenticationSucceededWithUserName": "{0} успішно авторизований", + "AuthenticationSucceededWithUserName": "{0} успішно автентифіковано", "Artists": "Виконавці", "Application": "Додаток", "AppDeviceValues": "Додаток: {0}, Пристрій: {1}", @@ -113,5 +112,10 @@ "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена", "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено", "Inherit": "Успадкувати", - "HeaderRecordingGroups": "Групи запису" + "HeaderRecordingGroups": "Групи запису", + "Forced": "Форсовані", + "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.", + "TaskCleanActivityLog": "Очистити журнал активності", + "Undefined": "Не визначено", + "Default": "За замовчуванням" } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index 9a5874e29..5d6d0775c 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -8,7 +8,7 @@ "Collections": "مجموعہ", "Folders": "فولڈرز", "HeaderLiveTV": "براہ راست ٹی وی", - "Channels": "چینل", + "Channels": "چینلز", "HeaderContinueWatching": "دیکھنا جاری رکھیں", "Playlists": "پلے لسٹس", "ValueSpecialEpisodeName": "خاص - {0}", @@ -17,7 +17,7 @@ "Artists": "فنکار", "Sync": "مطابقت", "Photos": "تصوریں", - "Albums": "البم", + "Albums": "البمز", "Favorites": "پسندیدہ", "Songs": "گانے", "Books": "کتابیں", @@ -105,7 +105,6 @@ "Inherit": "وراثت میں", "HomeVideos": "ہوم ویڈیو", "HeaderRecordingGroups": "ریکارڈنگ گروپس", - "HeaderCameraUploads": "کیمرہ اپلوڈز", "FailedLoginAttemptWithUserName": "لاگن کئ کوشش ناکام {0}", "DeviceOnlineWithName": "{0} متصل ھو چکا ھے", "DeviceOfflineWithName": "{0} منقطع ھو چکا ھے", diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json new file mode 100644 index 000000000..3d69e418b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -0,0 +1,123 @@ +{ + "Collections": "Bộ Sưu Tập", + "Favorites": "Yêu Thích", + "Folders": "Thư Mục", + "Genres": "Thể Loại", + "HeaderAlbumArtists": "Album Nghệ sĩ", + "HeaderContinueWatching": "Xem Tiếp", + "HeaderLiveTV": "TV Trực Tiếp", + "Movies": "Phim", + "Photos": "Ảnh", + "Playlists": "Danh sách phát", + "Shows": "Chương Trình TV", + "Songs": "Các Bài Hát", + "Sync": "Đồng Bộ", + "ValueSpecialEpisodeName": "Đặc Biệt - {0}", + "Albums": "Tuyển Tập", + "Artists": "Các Nghệ Sĩ", + "TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.", + "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu", + "TaskRefreshChannelsDescription": "Làm mới thông tin kênh internet.", + "TaskRefreshChannels": "Làm Mới Kênh", + "TaskCleanTranscodeDescription": "Xóa các tệp chuyển mã cũ hơn một ngày.", + "TaskCleanTranscode": "Làm Sạch Thư Mục Chuyển Mã", + "TaskUpdatePluginsDescription": "Tải xuống và cài đặt các bản cập nhật cho các plugin được định cấu hình để cập nhật tự động.", + "TaskUpdatePlugins": "Cập Nhật Plugins", + "TaskRefreshPeopleDescription": "Cập nhật thông tin chi tiết cho diễn viên và đạo diễn trong thư viện phương tiện của bạn.", + "TaskRefreshPeople": "Làm Mới Người Dùng", + "TaskCleanLogsDescription": "Xóa tập tin nhật ký cũ hơn {0} ngày.", + "TaskCleanLogs": "Làm Sạch Thư Mục Nhật Ký", + "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm tệp mới và làm mới dữ liệu mô tả.", + "TaskRefreshLibrary": "Quét Thư Viện Phương Tiện", + "TaskRefreshChapterImagesDescription": "Tạo hình thu nhỏ cho video có các phân cảnh.", + "TaskRefreshChapterImages": "Trích Xuất Ảnh Phân Cảnh", + "TaskCleanCacheDescription": "Xóa các tệp cache không còn cần thiết của hệ thống.", + "TaskCleanCache": "Làm Sạch Thư Mục Cache", + "TasksChannelsCategory": "Kênh Internet", + "TasksApplicationCategory": "Ứng Dụng", + "TasksLibraryCategory": "Thư Viện", + "TasksMaintenanceCategory": "Bảo Trì", + "VersionNumber": "Phiên Bản {0}", + "ValueHasBeenAddedToLibrary": "{0} đã được thêm vào thư viện của bạn", + "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}", + "UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}", + "UserPolicyUpdatedWithName": "Chính sách người dùng đã được cập nhật cho {0}", + "UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}", + "UserOnlineFromDevice": "{0} trực tuyến từ {1}", + "UserOfflineFromDevice": "{0} đã ngắt kết nối từ {1}", + "UserLockedOutWithName": "User {0} đã bị khóa", + "UserDownloadingItemWithValues": "{0} đang tải xuống {1}", + "UserDeletedWithName": "Người Dùng {0} đã được xóa", + "UserCreatedWithName": "Người Dùng {0} đã được tạo", + "User": "Người Dùng", + "TvShows": "Chương Trình TV", + "System": "Hệ Thống", + "SubtitleDownloadFailureFromForItem": "Không thể tải xuống phụ đề từ {0} cho {1}", + "StartupEmbyServerIsLoading": "Jellyfin Server đang tải. Vui lòng thử lại trong thời gian ngắn.", + "ServerNameNeedsToBeRestarted": "{0} cần được khởi động lại", + "ScheduledTaskStartedWithName": "{0} đã bắt đầu", + "ScheduledTaskFailedWithName": "{0} đã thất bại", + "ProviderValue": "Provider: {0}", + "PluginUpdatedWithName": "{0} đã cập nhật", + "PluginUninstalledWithName": "{0} đã được gỡ bỏ", + "PluginInstalledWithName": "{0} đã được cài đặt", + "Plugin": "Plugin", + "NotificationOptionVideoPlaybackStopped": "Phát lại video đã dừng", + "NotificationOptionVideoPlayback": "Đã bắt đầu phát lại video", + "NotificationOptionUserLockedOut": "Người dùng bị khóa", + "NotificationOptionTaskFailed": "Lỗi tác vụ đã lên lịch", + "NotificationOptionServerRestartRequired": "Yêu cầu khởi động lại Server", + "NotificationOptionPluginUpdateInstalled": "Cập nhật Plugin đã được cài đặt", + "NotificationOptionPluginUninstalled": "Đã gỡ bỏ Plugin", + "NotificationOptionPluginInstalled": "Đã cài đặt Plugin", + "NotificationOptionPluginError": "Thất bại Plugin", + "NotificationOptionNewLibraryContent": "Nội dung mới được thêm vào", + "NotificationOptionInstallationFailed": "Cài đặt thất bại", + "NotificationOptionCameraImageUploaded": "Đã tải lên hình ảnh máy ảnh", + "NotificationOptionAudioPlaybackStopped": "Phát lại âm thanh đã dừng", + "NotificationOptionAudioPlayback": "Phát lại âm thanh đã bắt đầu", + "NotificationOptionApplicationUpdateInstalled": "Bản cập nhật ứng dụng đã được cài đặt", + "NotificationOptionApplicationUpdateAvailable": "Bản cập nhật ứng dụng hiện sẵn có", + "NewVersionIsAvailable": "Một phiên bản mới của Jellyfin Server sẵn có để tải.", + "NameSeasonUnknown": "Không Rõ Mùa", + "NameSeasonNumber": "Phần {0}", + "NameInstallFailed": "{0} cài đặt thất bại", + "MusicVideos": "Videos Nhạc", + "Music": "Nhạc", + "MixedContent": "Nội dung hỗn hợp", + "MessageServerConfigurationUpdated": "Cấu hình máy chủ đã được cập nhật", + "MessageNamedServerConfigurationUpdatedWithValue": "Phần cấu hình máy chủ {0} đã được cập nhật", + "MessageApplicationUpdatedTo": "Jellyfin Server đã được cập nhật lên {0}", + "MessageApplicationUpdated": "Jellyfin Server đã được cập nhật", + "Latest": "Gần Nhất", + "LabelRunningTimeValue": "Thời Gian Chạy: {0}", + "LabelIpAddressValue": "Địa Chỉ IP: {0}", + "ItemRemovedWithName": "{0} đã xóa khỏi thư viện", + "ItemAddedWithName": "{0} được thêm vào thư viện", + "Inherit": "Thừa hưởng", + "HomeVideos": "Video nhà", + "HeaderRecordingGroups": "Nhóm Ghi Video", + "HeaderNextUp": "Tiếp Theo", + "HeaderFavoriteSongs": "Bài Hát Yêu Thích", + "HeaderFavoriteShows": "Chương Trình Yêu Thích", + "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", + "HeaderFavoriteArtists": "Nghệ Sĩ Yêu Thích", + "HeaderFavoriteAlbums": "Album Ưa Thích", + "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập thất bại từ {0}", + "DeviceOnlineWithName": "{0} đã kết nối", + "DeviceOfflineWithName": "{0} đã ngắt kết nối", + "ChapterNameValue": "Phân Cảnh {0}", + "Channels": "Các Kênh", + "CameraImageUploadedFrom": "Một hình ảnh máy ảnh mới đã được tải lên từ {0}", + "Books": "Sách", + "AuthenticationSucceededWithUserName": "{0} xác thực thành công", + "Application": "Ứng Dụng", + "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}", + "TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.", + "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động", + "Undefined": "Không Xác Định", + "Forced": "Bắt Buộc", + "Default": "Mặc Định", + "TaskOptimizeDatabaseDescription": "Thu gọn cơ sở dữ liệu và cắt bớt dung lượng trống. Chạy tác vụ này sau khi quét thư viện hoặc thực hiện các thay đổi khác ngụ ý sửa đổi cơ sở dữ liệu có thể cải thiện hiệu suất.", + "TaskOptimizeDatabase": "Tối ưu hóa cơ sở dữ liệu" +} diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 6b563a9b1..faa9c40e2 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -16,7 +16,6 @@ "Folders": "文件夹", "Genres": "风格", "HeaderAlbumArtists": "专辑作家", - "HeaderCameraUploads": "相机上传", "HeaderContinueWatching": "继续观影", "HeaderFavoriteAlbums": "收藏的专辑", "HeaderFavoriteArtists": "最爱的艺术家", @@ -114,5 +113,12 @@ "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。", "TaskCleanCache": "清理缓存目录", "TasksApplicationCategory": "应用程序", - "TasksMaintenanceCategory": "维护" + "TasksMaintenanceCategory": "维护", + "TaskCleanActivityLog": "清理程序日志", + "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。", + "Undefined": "未定义", + "Forced": "强制的", + "Default": "默认", + "TaskOptimizeDatabaseDescription": "压缩数据库并优化可用空间,在扫描库或执行其他数据库修改后运行此任务可能会提高性能。", + "TaskOptimizeDatabase": "优化数据库" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 1ac62baca..3dad21dcb 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -16,7 +16,6 @@ "Folders": "檔案夾", "Genres": "風格", "HeaderAlbumArtists": "專輯藝人", - "HeaderCameraUploads": "相機上載", "HeaderContinueWatching": "繼續觀看", "HeaderFavoriteAlbums": "最愛專輯", "HeaderFavoriteArtists": "最愛的藝人", @@ -114,5 +113,9 @@ "TaskCleanCache": "清理緩存目錄", "TasksChannelsCategory": "互聯網頻道", "TasksLibraryCategory": "庫", - "TaskRefreshPeople": "刷新人物" + "TaskRefreshPeople": "刷新人物", + "TaskCleanActivityLog": "清理活動記錄", + "Undefined": "未定義", + "Forced": "強制", + "Default": "預設" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index a21cdad95..c3b223f63 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -1,6 +1,6 @@ { "Albums": "專輯", - "AppDeviceValues": "軟體: {0}, 裝置: {1}", + "AppDeviceValues": "App:{0},裝置:{1}", "Application": "應用程式", "Artists": "演出者", "AuthenticationSucceededWithUserName": "{0} 成功授權", @@ -11,12 +11,11 @@ "Collections": "合輯", "DeviceOfflineWithName": "{0} 已經斷線", "DeviceOnlineWithName": "{0} 已經連線", - "FailedLoginAttemptWithUserName": "來自 {0} 的失敗登入嘗試", + "FailedLoginAttemptWithUserName": "來自使用者 {0} 的失敗登入", "Favorites": "我的最愛", "Folders": "資料夾", "Genres": "風格", "HeaderAlbumArtists": "專輯演出者", - "HeaderCameraUploads": "相機上傳", "HeaderContinueWatching": "繼續觀賞", "HeaderFavoriteAlbums": "最愛專輯", "HeaderFavoriteArtists": "最愛演出者", @@ -28,8 +27,8 @@ "HomeVideos": "自製影片", "ItemAddedWithName": "{0} 已新增至媒體庫", "ItemRemovedWithName": "{0} 已從媒體庫移除", - "LabelIpAddressValue": "IP 位置: {0}", - "LabelRunningTimeValue": "運行時間: {0}", + "LabelIpAddressValue": "IP 位址:{0}", + "LabelRunningTimeValue": "運行時間:{0}", "Latest": "最新", "MessageApplicationUpdated": "Jellyfin Server 已經更新", "MessageApplicationUpdatedTo": "Jellyfin Server 已經更新至 {0}", @@ -38,22 +37,22 @@ "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", - "MusicVideos": "音樂MV", + "MusicVideos": "音樂錄影帶", "NameInstallFailed": "{0} 安裝失敗", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季數", - "NewVersionIsAvailable": "新版本的Jellyfin Server 軟體已經推出可供下載。", + "NewVersionIsAvailable": "新版本的 Jellyfin Server 軟體已經可供下載。", "NotificationOptionApplicationUpdateAvailable": "有可用的應用程式更新", - "NotificationOptionApplicationUpdateInstalled": "應用程式已更新", + "NotificationOptionApplicationUpdateInstalled": "軟體更新已安裝", "NotificationOptionAudioPlayback": "音樂開始播放", "NotificationOptionAudioPlaybackStopped": "音樂停止播放", "NotificationOptionCameraImageUploaded": "相機相片已上傳", "NotificationOptionInstallationFailed": "安裝失敗", "NotificationOptionNewLibraryContent": "已新增新內容", - "NotificationOptionPluginError": "插件安裝錯誤", - "NotificationOptionPluginInstalled": "插件已安裝", - "NotificationOptionPluginUninstalled": "插件已移除", - "NotificationOptionPluginUpdateInstalled": "插件已更新", + "NotificationOptionPluginError": "外掛安裝失敗", + "NotificationOptionPluginInstalled": "外掛已安裝", + "NotificationOptionPluginUninstalled": "外掛已移除", + "NotificationOptionPluginUpdateInstalled": "外掛已更新", "NotificationOptionServerRestartRequired": "伺服器需要重新啟動", "NotificationOptionTaskFailed": "排程任務失敗", "NotificationOptionUserLockedOut": "使用者已鎖定", @@ -61,14 +60,14 @@ "NotificationOptionVideoPlaybackStopped": "影片停止播放", "Photos": "相片", "Playlists": "播放清單", - "Plugin": "插件", + "Plugin": "外掛", "PluginInstalledWithName": "{0} 已安裝", "PluginUninstalledWithName": "{0} 已移除", "PluginUpdatedWithName": "{0} 已更新", "ProviderValue": "提供商: {0}", - "ScheduledTaskFailedWithName": "{0} 已失敗", - "ScheduledTaskStartedWithName": "{0} 已開始", - "ServerNameNeedsToBeRestarted": "{0} 需要重新啟動", + "ScheduledTaskFailedWithName": "排程任務 {0} 已失敗", + "ScheduledTaskStartedWithName": "排程任務 {0} 已開始", + "ServerNameNeedsToBeRestarted": "伺服器 {0} 需要重新啟動", "Shows": "節目", "Songs": "歌曲", "StartupEmbyServerIsLoading": "Jellyfin Server正在啟動,請稍後再試一次。", @@ -78,10 +77,10 @@ "User": "使用者", "UserCreatedWithName": "使用者 {0} 已建立", "UserDeletedWithName": "使用者 {0} 已移除", - "UserDownloadingItemWithValues": "{0} 正在下載 {1}", + "UserDownloadingItemWithValues": "使用者 {0} 正在下載 {1}", "UserLockedOutWithName": "使用者 {0} 已鎖定", - "UserOfflineFromDevice": "{0} 已從 {1} 斷線", - "UserOnlineFromDevice": "{0} 已連線,來自 {1}", + "UserOfflineFromDevice": "使用者 {0} 已從 {1} 斷線", + "UserOnlineFromDevice": "使用者 {0} 已從 {1} 連線", "UserPasswordChangedWithName": "使用者 {0} 的密碼已變更", "UserPolicyUpdatedWithName": "使用者條約已更新為 {0}", "UserStartedPlayingItemWithValues": "{0}正在使用 {2} 播放 {1}", @@ -95,23 +94,28 @@ "TaskDownloadMissingSubtitlesDescription": "在網路上透過中繼資料搜尋遺失的字幕。", "TaskDownloadMissingSubtitles": "下載遺失的字幕", "TaskRefreshChannels": "重新整理頻道", - "TaskUpdatePlugins": "更新插件", - "TaskRefreshPeople": "重新整理人員", - "TaskCleanLogsDescription": "刪除超過{0}天的紀錄檔案。", + "TaskUpdatePlugins": "更新外掛", + "TaskRefreshPeople": "刷新用戶", + "TaskCleanLogsDescription": "刪除超過 {0} 天的舊紀錄檔。", "TaskCleanLogs": "清空紀錄資料夾", - "TaskRefreshLibraryDescription": "掃描媒體庫內新的檔案並重新整理描述資料。", - "TaskRefreshLibrary": "掃描媒體庫", + "TaskRefreshLibraryDescription": "重新掃描媒體庫的新檔案並更新描述資料。", + "TaskRefreshLibrary": "重新掃描媒體庫", "TaskRefreshChapterImages": "擷取章節圖片", - "TaskCleanCacheDescription": "刪除系統長時間不需要的快取。", + "TaskCleanCacheDescription": "刪除系統已不需要的快取。", "TaskCleanCache": "清除快取資料夾", "TasksLibraryCategory": "媒體庫", - "TaskRefreshChannelsDescription": "重新整理網絡頻道資料。", + "TaskRefreshChannelsDescription": "重新整理網路頻道資料。", "TaskCleanTranscodeDescription": "刪除超過一天的轉碼檔案。", "TaskCleanTranscode": "清除轉碼資料夾", - "TaskUpdatePluginsDescription": "下載並安裝配置為自動更新的插件的更新。", + "TaskUpdatePluginsDescription": "為設置自動更新的外掛下載並安裝更新。", "TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的中繼資料。", - "TaskRefreshChapterImagesDescription": "為有章節的視頻創建縮圖。", - "TasksChannelsCategory": "網絡頻道", + "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。", + "TasksChannelsCategory": "網路頻道", "TasksApplicationCategory": "應用程式", - "TasksMaintenanceCategory": "維修" + "TasksMaintenanceCategory": "維護", + "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。", + "TaskCleanActivityLog": "清除活動紀錄", + "Undefined": "未定義的", + "Forced": "強制", + "Default": "原本" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 90e2766b8..03919197e 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -3,13 +3,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; using System.Reflection; +using System.Text.Json; using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Localization @@ -20,11 +21,13 @@ namespace Emby.Server.Implementations.Localization public class LocalizationManager : ILocalizationManager { private const string DefaultCulture = "en-US"; + private const string RatingsPath = "Emby.Server.Implementations.Localization.Ratings."; + private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt"; + private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json"; private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; 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 = @@ -33,21 +36,20 @@ namespace Emby.Server.Implementations.Localization private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = new ConcurrentDictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase); - private List<CultureDto> _cultures; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + private List<CultureDto> _cultures = new List<CultureDto>(); /// <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; } @@ -57,44 +59,39 @@ namespace Emby.Server.Implementations.Localization /// <returns><see cref="Task" />.</returns> public async Task LoadAll() { - const string RatingsResource = "Emby.Server.Implementations.Localization.Ratings."; - // Extract from the assembly foreach (var resource in _assembly.GetManifestResourceNames()) { - if (!resource.StartsWith(RatingsResource, StringComparison.Ordinal)) + if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal)) { continue; } - string countryCode = resource.Substring(RatingsResource.Length, 2); + string countryCode = resource.Substring(RatingsPath.Length, 2); var dict = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase); - using (var str = _assembly.GetManifestResourceStream(resource)) - using (var reader = new StreamReader(str)) + await using var stream = _assembly.GetManifestResourceStream(resource); + using var reader = new StreamReader(stream!); // shouldn't be null here, we just got the resource path from Assembly.GetManifestResourceNames() + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - string line; - while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] parts = line.Split(','); + if (parts.Length == 2 + && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - string[] parts = line.Split(','); - if (parts.Length == 2 - && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) - { - var name = parts[0]; - dict.Add(name, new ParentalRating(name, value)); - } + var name = parts[0]; + dict.Add(name, new ParentalRating(name, value)); + } #if DEBUG - else - { - _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); - } -#endif + else + { + _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); } +#endif } _allParentalRatings[countryCode] = dict; @@ -114,54 +111,49 @@ namespace Emby.Server.Implementations.Localization { List<CultureDto> list = new List<CultureDto>(); - const string ResourcePath = "Emby.Server.Implementations.Localization.iso6392.txt"; - - using (var stream = _assembly.GetManifestResourceStream(ResourcePath)) - using (var reader = new StreamReader(stream)) + await using var stream = _assembly.GetManifestResourceStream(CulturesPath) + ?? throw new InvalidOperationException($"Invalid resource path: '{CulturesPath}'"); + using var reader = new StreamReader(stream); + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - while (!reader.EndOfStream) + if (string.IsNullOrWhiteSpace(line)) { - var line = await reader.ReadLineAsync().ConfigureAwait(false); + continue; + } - if (string.IsNullOrWhiteSpace(line)) + var parts = line.Split('|'); + + if (parts.Length == 5) + { + string name = parts[3]; + if (string.IsNullOrWhiteSpace(name)) { continue; } - var parts = line.Split('|'); + string twoCharName = parts[2]; + if (string.IsNullOrWhiteSpace(twoCharName)) + { + continue; + } - if (parts.Length == 5) + string[] threeletterNames; + if (string.IsNullOrWhiteSpace(parts[1])) { - string name = parts[3]; - if (string.IsNullOrWhiteSpace(name)) - { - continue; - } - - string twoCharName = parts[2]; - if (string.IsNullOrWhiteSpace(twoCharName)) - { - continue; - } - - string[] threeletterNames; - if (string.IsNullOrWhiteSpace(parts[1])) - { - threeletterNames = new[] { parts[0] }; - } - else - { - threeletterNames = new[] { parts[0], parts[1] }; - } - - list.Add(new CultureDto - { - DisplayName = name, - Name = name, - ThreeLetterISOLanguageNames = threeletterNames, - TwoLetterISOLanguageName = twoCharName - }); + threeletterNames = new[] { parts[0] }; } + else + { + threeletterNames = new[] { parts[0], parts[1] }; + } + + list.Add(new CultureDto + { + DisplayName = name, + Name = name, + ThreeLetterISOLanguageNames = threeletterNames, + TwoLetterISOLanguageName = twoCharName + }); } } @@ -169,18 +161,32 @@ namespace Emby.Server.Implementations.Localization } /// <inheritdoc /> - public CultureDto FindLanguageInfo(string language) - => GetCultures() - .FirstOrDefault(i => - string.Equals(i.DisplayName, language, StringComparison.OrdinalIgnoreCase) - || string.Equals(i.Name, language, StringComparison.OrdinalIgnoreCase) - || i.ThreeLetterISOLanguageNames.Contains(language, StringComparer.OrdinalIgnoreCase) - || string.Equals(i.TwoLetterISOLanguageName, language, StringComparison.OrdinalIgnoreCase)); + public CultureDto? FindLanguageInfo(string language) + { + // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs + for (var i = 0; i < _cultures.Count; i++) + { + var culture = _cultures[i]; + if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase) + || language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase) + || culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase) + || language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) + { + return culture; + } + } + + return default; + } /// <inheritdoc /> public IEnumerable<CountryInfo> GetCountries() - => _jsonSerializer.DeserializeFromStream<IEnumerable<CountryInfo>>( - _assembly.GetManifestResourceStream("Emby.Server.Implementations.Localization.countries.json")); + { + using StreamReader reader = new StreamReader( + _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'")); + return JsonSerializer.Deserialize<IEnumerable<CountryInfo>>(reader.ReadToEnd(), _jsonOptions) + ?? throw new InvalidOperationException($"Resource contains invalid data: '{CountriesPath}'"); + } /// <inheritdoc /> public IEnumerable<ParentalRating> GetParentalRatings() @@ -199,7 +205,9 @@ namespace Emby.Server.Implementations.Localization countryCode = "us"; } - return GetRatings(countryCode) ?? GetRatings("us"); + return GetRatings(countryCode) + ?? GetRatings("us") + ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); } /// <summary> @@ -207,7 +215,7 @@ namespace Emby.Server.Implementations.Localization /// </summary> /// <param name="countryCode">The country code.</param> /// <returns>The ratings.</returns> - private Dictionary<string, ParentalRating> GetRatings(string countryCode) + private Dictionary<string, ParentalRating>? GetRatings(string countryCode) { _allParentalRatings.TryGetValue(countryCode, out var value); @@ -222,7 +230,7 @@ namespace Emby.Server.Implementations.Localization throw new ArgumentNullException(nameof(rating)); } - if (_unratedValues.Contains(rating, StringComparer.OrdinalIgnoreCase)) + if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase)) { return null; } @@ -232,7 +240,7 @@ namespace Emby.Server.Implementations.Localization var ratingsDictionary = GetParentalRatingsDictionary(); - if (ratingsDictionary.TryGetValue(rating, out ParentalRating value)) + if (ratingsDictionary.TryGetValue(rating, out ParentalRating? value)) { return value.Value; } @@ -250,11 +258,11 @@ namespace Emby.Server.Implementations.Localization var index = rating.IndexOf(':', StringComparison.Ordinal); if (index != -1) { - rating = rating.Substring(index).TrimStart(':').Trim(); + var trimmedRating = rating.AsSpan(index).TrimStart(':').Trim(); - if (!string.IsNullOrWhiteSpace(rating)) + if (!trimmedRating.IsEmpty) { - return GetRatingLevel(rating); + return GetRatingLevel(trimmedRating.ToString()); } } @@ -263,20 +271,6 @@ namespace Emby.Server.Implementations.Localization } /// <inheritdoc /> - public bool HasUnicodeCategory(string value, UnicodeCategory category) - { - foreach (var chr in value) - { - if (char.GetUnicodeCategory(chr) == category) - { - return true; - } - } - - return false; - } - - /// <inheritdoc /> public string GetLocalizedString(string phrase) { return GetLocalizedString(phrase, _configurationManager.Configuration.UICulture); @@ -313,11 +307,11 @@ namespace Emby.Server.Implementations.Localization } const string Prefix = "Core"; - var key = Prefix + culture; return _dictionaries.GetOrAdd( - key, - f => GetDictionary(Prefix, culture, DefaultCulture + ".json").GetAwaiter().GetResult()); + culture, + (key, localizationManager) => localizationManager.GetDictionary(Prefix, key, DefaultCulture + ".json").GetAwaiter().GetResult(), + this); } private async Task<Dictionary<string, string>> GetDictionary(string prefix, string culture, string baseFilename) @@ -339,22 +333,23 @@ namespace Emby.Server.Implementations.Localization private async Task CopyInto(IDictionary<string, string> dictionary, string resourcePath) { - using (var stream = _assembly.GetManifestResourceStream(resourcePath)) + await using var stream = _assembly.GetManifestResourceStream(resourcePath); + // 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) { - // 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); + _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath); + return; + } - foreach (var key in dict.Keys) - { - dictionary[key] = dict[key]; - } - } - else - { - _logger.LogError("Missing translation/culture resource: {ResourcePath}", resourcePath); - } + var dict = await JsonSerializer.DeserializeAsync<Dictionary<string, string>>(stream, _jsonOptions).ConfigureAwait(false); + if (dict == null) + { + throw new InvalidOperationException($"Resource contains invalid data: '{stream}'"); + } + + foreach (var key in dict.Keys) + { + dictionary[key] = dict[key]; } } @@ -413,6 +408,7 @@ namespace Emby.Server.Implementations.Localization yield return new LocalizationOption("Swedish", "sv"); yield return new LocalizationOption("Swiss German", "gsw"); yield return new LocalizationOption("Turkish", "tr"); + yield return new LocalizationOption("Tiếng Việt", "vi"); } } } diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json index 581e9f835..b08a3ae79 100644 --- a/Emby.Server.Implementations/Localization/countries.json +++ b/Emby.Server.Implementations/Localization/countries.json @@ -558,6 +558,12 @@ "TwoLetterISORegionName": "OM" }, { + "DisplayName": "Palestine", + "Name": "PS", + "ThreeLetterISORegionName": "PSE", + "TwoLetterISORegionName": "PS" + }, + { "DisplayName": "Panama", "Name": "PA", "ThreeLetterISORegionName": "PAN", diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index 40f8614f1..488901822 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -77,6 +77,8 @@ chb|||Chibcha|chibcha che||ce|Chechen|tchétchène chg|||Chagatai|djaghataï chi|zho|zh|Chinese|chinois +chi|zho|zh-tw|Chinese; Traditional|chinois +chi|zho|zh-hk|Chinese; Hong Kong|chinois chk|||Chuukese|chuuk chm|||Mari|mari chn|||Chinook jargon|chinook, jargon diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index 438bbe24a..8aaa1f7bb 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -12,9 +14,9 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.MediaEncoder @@ -81,16 +83,6 @@ namespace Emby.Server.Implementations.MediaEncoder return false; } - if (video.VideoType == VideoType.Iso) - { - return false; - } - - if (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd) - { - return false; - } - if (video.IsShortcut) { return false; @@ -140,15 +132,19 @@ namespace Emby.Server.Implementations.MediaEncoder // Add some time for the first chapter to make sure we don't end up with a black image var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks); - var protocol = MediaProtocol.File; - - var inputPath = MediaEncoderHelpers.GetInputArgument(_fileSystem, video.Path, null, Array.Empty<string>()); + var inputPath = video.Path; Directory.CreateDirectory(Path.GetDirectoryName(path)); var container = video.Container; + var mediaSource = new MediaSourceInfo + { + VideoType = video.VideoType, + IsoType = video.IsoType, + Protocol = video.PathProtocol.Value, + }; - var tempFile = await _encoder.ExtractVideoImage(inputPath, container, protocol, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false); + var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false); File.Copy(tempFile, path, true); try @@ -166,7 +162,7 @@ namespace Emby.Server.Implementations.MediaEncoder } catch (Exception ex) { - _logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(",", video.Path)); + _logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(',', video.Path)); success = false; break; } diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 0781a0e33..137728616 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs index 4e25768cf..a8b18d292 100644 --- a/Emby.Server.Implementations/Net/UdpSocket.cs +++ b/Emby.Server.Implementations/Net/UdpSocket.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Networking/NetworkManager.cs b/Emby.Server.Implementations/Networking/NetworkManager.cs deleted file mode 100644 index 089ec30e6..000000000 --- a/Emby.Server.Implementations/Networking/NetworkManager.cs +++ /dev/null @@ -1,556 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Networking -{ - /// <summary> - /// Class to take care of network interface management. - /// </summary> - public class NetworkManager : INetworkManager - { - private readonly ILogger<NetworkManager> _logger; - - private IPAddress[] _localIpAddresses; - private readonly object _localIpAddressSyncLock = new object(); - - private readonly object _subnetLookupLock = new object(); - private readonly Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal); - - private List<PhysicalAddress> _macAddresses; - - /// <summary> - /// Initializes a new instance of the <see cref="NetworkManager"/> class. - /// </summary> - /// <param name="logger">Logger to use for messages.</param> - public NetworkManager(ILogger<NetworkManager> logger) - { - _logger = logger; - - NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; - NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; - } - - /// <inheritdoc/> - public event EventHandler NetworkChanged; - - /// <inheritdoc/> - public Func<string[]> LocalSubnetsFn { get; set; } - - private void OnNetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e) - { - _logger.LogDebug("NetworkAvailabilityChanged"); - OnNetworkChanged(); - } - - private void OnNetworkAddressChanged(object sender, EventArgs e) - { - _logger.LogDebug("NetworkAddressChanged"); - OnNetworkChanged(); - } - - private void OnNetworkChanged() - { - lock (_localIpAddressSyncLock) - { - _localIpAddresses = null; - _macAddresses = null; - } - - NetworkChanged?.Invoke(this, EventArgs.Empty); - } - - /// <inheritdoc/> - public IPAddress[] GetLocalIpAddresses() - { - lock (_localIpAddressSyncLock) - { - if (_localIpAddresses == null) - { - var addresses = GetLocalIpAddressesInternal().ToArray(); - - _localIpAddresses = addresses; - } - - return _localIpAddresses; - } - } - - private List<IPAddress> GetLocalIpAddressesInternal() - { - var list = GetIPsDefault().ToList(); - - if (list.Count == 0) - { - list = GetLocalIpAddressesFallback().GetAwaiter().GetResult().ToList(); - } - - var listClone = new List<IPAddress>(); - - var subnets = LocalSubnetsFn(); - - foreach (var i in list) - { - if (i.IsIPv6LinkLocal || i.ToString().StartsWith("169.254.", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (Array.IndexOf(subnets, $"[{i}]") == -1) - { - listClone.Add(i); - } - } - - return listClone - .OrderBy(i => i.AddressFamily == AddressFamily.InterNetwork ? 0 : 1) - // .ThenBy(i => listClone.IndexOf(i)) - .GroupBy(i => i.ToString()) - .Select(x => x.First()) - .ToList(); - } - - /// <inheritdoc/> - public bool IsInPrivateAddressSpace(string endpoint) - { - return IsInPrivateAddressSpace(endpoint, true); - } - - // Checks if the address in endpoint is an RFC1918, RFC1122, or RFC3927 address - private bool IsInPrivateAddressSpace(string endpoint, bool checkSubnets) - { - if (string.Equals(endpoint, "::1", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - // IPV6 - if (endpoint.Split('.').Length > 4) - { - // Handle ipv4 mapped to ipv6 - var originalEndpoint = endpoint; - endpoint = endpoint.Replace("::ffff:", string.Empty, StringComparison.OrdinalIgnoreCase); - - if (string.Equals(endpoint, originalEndpoint, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - // Private address space: - - if (string.Equals(endpoint, "localhost", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (!IPAddress.TryParse(endpoint, out var ipAddress)) - { - return false; - } - - byte[] octet = ipAddress.GetAddressBytes(); - - if ((octet[0] == 10) || - (octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918 - (octet[0] == 192 && octet[1] == 168) || // RFC1918 - (octet[0] == 127) || // RFC1122 - (octet[0] == 169 && octet[1] == 254)) // RFC3927 - { - return true; - } - - if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint)) - { - return true; - } - - return false; - } - - /// <inheritdoc/> - public bool IsInPrivateAddressSpaceAndLocalSubnet(string endpoint) - { - if (endpoint.StartsWith("10.", StringComparison.OrdinalIgnoreCase)) - { - var endpointFirstPart = endpoint.Split('.')[0]; - - var subnets = GetSubnets(endpointFirstPart); - - foreach (var subnet_Match in subnets) - { - // logger.LogDebug("subnet_Match:" + subnet_Match); - - if (endpoint.StartsWith(subnet_Match + ".", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - - return false; - } - - // Gives a list of possible subnets from the system whose interface ip starts with endpointFirstPart - private List<string> GetSubnets(string endpointFirstPart) - { - lock (_subnetLookupLock) - { - if (_subnetLookup.TryGetValue(endpointFirstPart, out var subnets)) - { - return subnets; - } - - subnets = new List<string>(); - - foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) - { - foreach (var unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses) - { - if (unicastIPAddressInformation.Address.AddressFamily == AddressFamily.InterNetwork && endpointFirstPart == unicastIPAddressInformation.Address.ToString().Split('.')[0]) - { - int subnet_Test = 0; - foreach (string part in unicastIPAddressInformation.IPv4Mask.ToString().Split('.')) - { - if (part.Equals("0", StringComparison.Ordinal)) - { - break; - } - - subnet_Test++; - } - - var subnet_Match = string.Join(".", unicastIPAddressInformation.Address.ToString().Split('.').Take(subnet_Test).ToArray()); - - // TODO: Is this check necessary? - if (adapter.OperationalStatus == OperationalStatus.Up) - { - subnets.Add(subnet_Match); - } - } - } - } - - _subnetLookup[endpointFirstPart] = subnets; - - return subnets; - } - } - - /// <inheritdoc/> - public bool IsInLocalNetwork(string endpoint) - { - return IsInLocalNetworkInternal(endpoint, true); - } - - /// <inheritdoc/> - public bool IsAddressInSubnets(string addressString, string[] subnets) - { - return IsAddressInSubnets(IPAddress.Parse(addressString), addressString, subnets); - } - - /// <inheritdoc/> - public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC) - { - byte[] octet = address.GetAddressBytes(); - - if ((octet[0] == 127) || // RFC1122 - (octet[0] == 169 && octet[1] == 254)) // RFC3927 - { - // don't use on loopback or 169 interfaces - return false; - } - - string addressString = address.ToString(); - string excludeAddress = "[" + addressString + "]"; - var subnets = LocalSubnetsFn(); - - // Include any address if LAN subnets aren't specified - if (subnets.Length == 0) - { - return true; - } - - // Exclude any addresses if they appear in the LAN list in [ ] - if (Array.IndexOf(subnets, excludeAddress) != -1) - { - return false; - } - - return IsAddressInSubnets(address, addressString, subnets); - } - - /// <summary> - /// Checks if the give address falls within the ranges given in [subnets]. The addresses in subnets can be hosts or subnets in the CIDR format. - /// </summary> - /// <param name="address">IPAddress version of the address.</param> - /// <param name="addressString">The address to check.</param> - /// <param name="subnets">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param> - /// <returns><c>false</c>if the address isn't in the subnets, <c>true</c> otherwise.</returns> - private static bool IsAddressInSubnets(IPAddress address, string addressString, string[] subnets) - { - foreach (var subnet in subnets) - { - var normalizedSubnet = subnet.Trim(); - // Is the subnet a host address and does it match the address being passes? - if (string.Equals(normalizedSubnet, addressString, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - // Parse CIDR subnets and see if address falls within it. - if (normalizedSubnet.Contains('/', StringComparison.Ordinal)) - { - try - { - var ipNetwork = IPNetwork.Parse(normalizedSubnet); - if (ipNetwork.Contains(address)) - { - return true; - } - } - catch - { - // Ignoring - invalid subnet passed encountered. - } - } - } - - return false; - } - - private bool IsInLocalNetworkInternal(string endpoint, bool resolveHost) - { - if (string.IsNullOrEmpty(endpoint)) - { - throw new ArgumentNullException(nameof(endpoint)); - } - - if (IPAddress.TryParse(endpoint, out var address)) - { - var addressString = address.ToString(); - - var localSubnetsFn = LocalSubnetsFn; - if (localSubnetsFn != null) - { - var localSubnets = localSubnetsFn(); - foreach (var subnet in localSubnets) - { - // Only validate if there's at least one valid entry. - if (!string.IsNullOrWhiteSpace(subnet)) - { - return IsAddressInSubnets(address, addressString, localSubnets) || IsInPrivateAddressSpace(addressString, false); - } - } - } - - int lengthMatch = 100; - if (address.AddressFamily == AddressFamily.InterNetwork) - { - lengthMatch = 4; - if (IsInPrivateAddressSpace(addressString, true)) - { - return true; - } - } - else if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - lengthMatch = 9; - if (IsInPrivateAddressSpace(endpoint, true)) - { - return true; - } - } - - // Should be even be doing this with ipv6? - if (addressString.Length >= lengthMatch) - { - var prefix = addressString.Substring(0, lengthMatch); - - if (GetLocalIpAddresses().Any(i => i.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - } - } - else if (resolveHost) - { - if (Uri.TryCreate(endpoint, UriKind.RelativeOrAbsolute, out var uri)) - { - try - { - var host = uri.DnsSafeHost; - _logger.LogDebug("Resolving host {0}", host); - - address = GetIpAddresses(host).GetAwaiter().GetResult().FirstOrDefault(); - - if (address != null) - { - _logger.LogDebug("{0} resolved to {1}", host, address); - - return IsInLocalNetworkInternal(address.ToString(), false); - } - } - catch (InvalidOperationException) - { - // Can happen with reverse proxy or IIS url rewriting? - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resolving hostname"); - } - } - } - - return false; - } - - private static Task<IPAddress[]> GetIpAddresses(string hostName) - { - return Dns.GetHostAddressesAsync(hostName); - } - - private IEnumerable<IPAddress> GetIPsDefault() - { - IEnumerable<NetworkInterface> interfaces; - - try - { - interfaces = NetworkInterface.GetAllNetworkInterfaces() - .Where(x => x.OperationalStatus == OperationalStatus.Up - || x.OperationalStatus == OperationalStatus.Unknown); - } - catch (NetworkInformationException ex) - { - _logger.LogError(ex, "Error in GetAllNetworkInterfaces"); - return Enumerable.Empty<IPAddress>(); - } - - return interfaces.SelectMany(network => - { - var ipProperties = network.GetIPProperties(); - - // Exclude any addresses if they appear in the LAN list in [ ] - - return ipProperties.UnicastAddresses - .Select(i => i.Address) - .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6); - }).GroupBy(i => i.ToString()) - .Select(x => x.First()); - } - - private static async Task<IEnumerable<IPAddress>> GetLocalIpAddressesFallback() - { - var host = await Dns.GetHostEntryAsync(Dns.GetHostName()).ConfigureAwait(false); - - // Reverse them because the last one is usually the correct one - // It's not fool-proof so ultimately the consumer will have to examine them and decide - return host.AddressList - .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6) - .Reverse(); - } - - /// <summary> - /// Gets a random port number that is currently available. - /// </summary> - /// <returns>System.Int32.</returns> - public int GetRandomUnusedTcpPort() - { - var listener = new TcpListener(IPAddress.Any, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } - - /// <inheritdoc/> - public int GetRandomUnusedUdpPort() - { - var localEndPoint = new IPEndPoint(IPAddress.Any, 0); - using (var udpClient = new UdpClient(localEndPoint)) - { - return ((IPEndPoint)udpClient.Client.LocalEndPoint).Port; - } - } - - /// <inheritdoc/> - public List<PhysicalAddress> GetMacAddresses() - { - return _macAddresses ??= GetMacAddressesInternal().ToList(); - } - - private static IEnumerable<PhysicalAddress> GetMacAddressesInternal() - => NetworkInterface.GetAllNetworkInterfaces() - .Where(i => i.NetworkInterfaceType != NetworkInterfaceType.Loopback) - .Select(x => x.GetPhysicalAddress()) - .Where(x => !x.Equals(PhysicalAddress.None)); - - /// <inheritdoc/> - public bool IsInSameSubnet(IPAddress address1, IPAddress address2, IPAddress subnetMask) - { - IPAddress network1 = GetNetworkAddress(address1, subnetMask); - IPAddress network2 = GetNetworkAddress(address2, subnetMask); - return network1.Equals(network2); - } - - private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask) - { - byte[] ipAdressBytes = address.GetAddressBytes(); - byte[] subnetMaskBytes = subnetMask.GetAddressBytes(); - - if (ipAdressBytes.Length != subnetMaskBytes.Length) - { - throw new ArgumentException("Lengths of IP address and subnet mask do not match."); - } - - byte[] broadcastAddress = new byte[ipAdressBytes.Length]; - for (int i = 0; i < broadcastAddress.Length; i++) - { - broadcastAddress[i] = (byte)(ipAdressBytes[i] & subnetMaskBytes[i]); - } - - return new IPAddress(broadcastAddress); - } - - /// <inheritdoc/> - public IPAddress GetLocalIpSubnetMask(IPAddress address) - { - NetworkInterface[] interfaces; - - try - { - var validStatuses = new[] { OperationalStatus.Up, OperationalStatus.Unknown }; - - interfaces = NetworkInterface.GetAllNetworkInterfaces() - .Where(i => validStatuses.Contains(i.OperationalStatus)) - .ToArray(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in GetAllNetworkInterfaces"); - return null; - } - - foreach (NetworkInterface ni in interfaces) - { - foreach (UnicastIPAddressInformation ip in ni.GetIPProperties().UnicastAddresses) - { - if (ip.Address.Equals(address) && ip.IPv4Mask != null) - { - return ip.IPv4Mask; - } - } - } - - return null; - } - } -} diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index d3b64fb31..8cafde38e 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -145,12 +147,12 @@ namespace Emby.Server.Implementations.Playlists playlist.SetMediaType(options.MediaType); - parentFolder.AddChild(playlist, CancellationToken.None); + parentFolder.AddChild(playlist); await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) .ConfigureAwait(false); - if (options.ItemIdList.Length > 0) + if (options.ItemIdList.Count > 0) { await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false) { @@ -184,7 +186,7 @@ namespace Emby.Server.Implementations.Playlists return Playlist.GetPlaylistItems(playlistMediaType, items, user, options); } - public Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId) + public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId) { var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId); @@ -194,7 +196,7 @@ namespace Emby.Server.Implementations.Playlists }); } - private async Task AddToPlaylistInternal(Guid playlistId, ICollection<Guid> newItemIds, User user, DtoOptions options) + private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options) { // Retrieve the existing playlist var playlist = _libraryManager.GetItemById(playlistId) as Playlist @@ -215,7 +217,7 @@ namespace Emby.Server.Implementations.Playlists // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems - .Select(i => LinkedChild.Create(i)) + .Select(LinkedChild.Create) .ToList(); // Log duplicates that have been ignored, if any diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs new file mode 100644 index 000000000..fc0920edf --- /dev/null +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -0,0 +1,756 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using MediaBrowser.Common; +using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Common.Net; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Updates; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Plugins +{ + /// <summary> + /// Defines the <see cref="PluginManager" />. + /// </summary> + public class PluginManager : IPluginManager + { + private readonly string _pluginsPath; + private readonly Version _appVersion; + private readonly JsonSerializerOptions _jsonOptions; + private readonly ILogger<PluginManager> _logger; + private readonly IApplicationHost _appHost; + private readonly ServerConfiguration _config; + private readonly List<LocalPlugin> _plugins; + private readonly Version _minimumVersion; + + private IHttpClientFactory? _httpClientFactory; + + private IHttpClientFactory HttpClientFactory + { + get + { + return _httpClientFactory ?? (_httpClientFactory = _appHost.Resolve<IHttpClientFactory>()); + } + } + + /// <summary> + /// Initializes a new instance of the <see cref="PluginManager"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger"/>.</param> + /// <param name="appHost">The <see cref="IApplicationHost"/>.</param> + /// <param name="config">The <see cref="ServerConfiguration"/>.</param> + /// <param name="pluginsPath">The plugin path.</param> + /// <param name="appVersion">The application version.</param> + public PluginManager( + ILogger<PluginManager> logger, + IApplicationHost appHost, + ServerConfiguration config, + string pluginsPath, + Version appVersion) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _pluginsPath = pluginsPath; + _appVersion = appVersion ?? throw new ArgumentNullException(nameof(appVersion)); + _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options) + { + WriteIndented = true + }; + + // We need to use the default GUID converter, so we need to remove any custom ones. + for (int a = _jsonOptions.Converters.Count - 1; a >= 0; a--) + { + if (_jsonOptions.Converters[a] is JsonGuidConverter convertor) + { + _jsonOptions.Converters.Remove(convertor); + break; + } + } + + _config = config; + _appHost = appHost; + _minimumVersion = new Version(0, 0, 0, 1); + _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>(); + } + + /// <summary> + /// Gets the Plugins. + /// </summary> + public IReadOnlyList<LocalPlugin> Plugins => _plugins; + + /// <summary> + /// Returns all the assemblies. + /// </summary> + /// <returns>An IEnumerable{Assembly}.</returns> + public IEnumerable<Assembly> LoadAssemblies() + { + // Attempt to remove any deleted plugins and change any successors to be active. + for (int i = _plugins.Count - 1; i >= 0; i--) + { + var plugin = _plugins[i]; + if (plugin.Manifest.Status == PluginStatus.Deleted && DeletePlugin(plugin)) + { + // See if there is another version, and if so make that active. + ProcessAlternative(plugin); + } + } + + // Now load the assemblies.. + foreach (var plugin in _plugins) + { + UpdatePluginSuperceedStatus(plugin); + + if (plugin.IsEnabledAndSupported == false) + { + _logger.LogInformation("Skipping disabled plugin {Version} of {Name} ", plugin.Version, plugin.Name); + continue; + } + + foreach (var file in plugin.DllFiles) + { + Assembly assembly; + try + { + assembly = Assembly.LoadFrom(file); + + assembly.GetExportedTypes(); + } + catch (FileLoadException ex) + { + _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file); + ChangePluginState(plugin, PluginStatus.Malfunctioned); + continue; + } + catch (TypeLoadException ex) // Undocumented exception + { + _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references an incompatible version of one of the shared libraries. Disabling plugin.", file); + ChangePluginState(plugin, PluginStatus.NotSupported); + continue; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin.", file); + ChangePluginState(plugin, PluginStatus.Malfunctioned); + continue; + } + + _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, file); + yield return assembly; + } + } + } + + /// <summary> + /// Creates all the plugin instances. + /// </summary> + public void CreatePlugins() + { + _ = _appHost.GetExports<IPlugin>(CreatePluginInstance); + } + + /// <summary> + /// Registers the plugin's services with the DI. + /// Note: DI is not yet instantiated yet. + /// </summary> + /// <param name="serviceCollection">A <see cref="ServiceCollection"/> instance.</param> + public void RegisterServices(IServiceCollection serviceCollection) + { + foreach (var pluginServiceRegistrator in _appHost.GetExportTypes<IPluginServiceRegistrator>()) + { + var plugin = GetPluginByAssembly(pluginServiceRegistrator.Assembly); + if (plugin == null) + { + _logger.LogError("Unable to find plugin in assembly {Assembly}", pluginServiceRegistrator.Assembly.FullName); + continue; + } + + UpdatePluginSuperceedStatus(plugin); + if (!plugin.IsEnabledAndSupported) + { + continue; + } + + try + { + var instance = (IPluginServiceRegistrator?)Activator.CreateInstance(pluginServiceRegistrator); + instance?.RegisterServices(serviceCollection); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogError(ex, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator.Assembly.FullName); + if (ChangePluginState(plugin, PluginStatus.Malfunctioned)) + { + _logger.LogInformation("Disabling plugin {Path}", plugin.Path); + } + } + } + } + + /// <summary> + /// Imports a plugin manifest from <paramref name="folder"/>. + /// </summary> + /// <param name="folder">Folder of the plugin.</param> + public void ImportPluginFrom(string folder) + { + if (string.IsNullOrEmpty(folder)) + { + throw new ArgumentNullException(nameof(folder)); + } + + // Load the plugin. + var plugin = LoadManifest(folder); + // Make sure we haven't already loaded this. + if (_plugins.Any(p => p.Manifest.Equals(plugin.Manifest))) + { + return; + } + + _plugins.Add(plugin); + EnablePlugin(plugin); + } + + /// <summary> + /// Removes the plugin reference '<paramref name="plugin"/>. + /// </summary> + /// <param name="plugin">The plugin.</param> + /// <returns>Outcome of the operation.</returns> + public bool RemovePlugin(LocalPlugin plugin) + { + if (plugin == null) + { + throw new ArgumentNullException(nameof(plugin)); + } + + if (DeletePlugin(plugin)) + { + ProcessAlternative(plugin); + return true; + } + + _logger.LogWarning("Unable to delete {Path}, so marking as deleteOnStartup.", plugin.Path); + // Unable to delete, so disable. + if (ChangePluginState(plugin, PluginStatus.Deleted)) + { + ProcessAlternative(plugin); + return true; + } + + return false; + } + + /// <summary> + /// Attempts to find the plugin with and id of <paramref name="id"/>. + /// </summary> + /// <param name="id">The <see cref="Guid"/> of plugin.</param> + /// <param name="version">Optional <see cref="Version"/> of the plugin to locate.</param> + /// <returns>A <see cref="LocalPlugin"/> if located, or null if not.</returns> + public LocalPlugin? GetPlugin(Guid id, Version? version = null) + { + LocalPlugin? plugin; + + if (version == null) + { + // If no version is given, return the current instance. + var plugins = _plugins.Where(p => p.Id.Equals(id)).ToList(); + + plugin = plugins.FirstOrDefault(p => p.Instance != null) ?? plugins.OrderByDescending(p => p.Version).FirstOrDefault(); + } + else + { + // Match id and version number. + plugin = _plugins.FirstOrDefault(p => p.Id.Equals(id) && p.Version.Equals(version)); + } + + return plugin; + } + + /// <summary> + /// Enables the plugin, disabling all other versions. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> + public void EnablePlugin(LocalPlugin plugin) + { + if (plugin == null) + { + throw new ArgumentNullException(nameof(plugin)); + } + + if (ChangePluginState(plugin, PluginStatus.Active)) + { + // See if there is another version, and if so, supercede it. + ProcessAlternative(plugin); + } + } + + /// <summary> + /// Disable the plugin. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> + public void DisablePlugin(LocalPlugin plugin) + { + if (plugin == null) + { + throw new ArgumentNullException(nameof(plugin)); + } + + // Update the manifest on disk + if (ChangePluginState(plugin, PluginStatus.Disabled)) + { + // If there is another version, activate it. + ProcessAlternative(plugin); + } + } + + /// <summary> + /// Disable the plugin. + /// </summary> + /// <param name="assembly">The <see cref="Assembly"/> of the plug to disable.</param> + public void FailPlugin(Assembly assembly) + { + // Only save if disabled. + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + var plugin = _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location)); + if (plugin == null) + { + // A plugin's assembly didn't cause this issue, so ignore it. + return; + } + + ChangePluginState(plugin, PluginStatus.Malfunctioned); + } + + /// <inheritdoc/> + public bool SaveManifest(PluginManifest manifest, string path) + { + try + { + var data = JsonSerializer.Serialize(manifest, _jsonOptions); + File.WriteAllText(Path.Combine(path, "meta.json"), data); + return true; + } + catch (ArgumentException e) + { + _logger.LogWarning(e, "Unable to save plugin manifest due to invalid value. {Path}", path); + return false; + } + } + + /// <inheritdoc/> + public async Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) + { + if (packageInfo == null) + { + return false; + } + + var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); + var imagePath = string.Empty; + + if (!string.IsNullOrEmpty(packageInfo.ImageUrl)) + { + var url = new Uri(packageInfo.ImageUrl); + imagePath = Path.Join(path, url.Segments[^1]); + + await using var fileStream = File.OpenWrite(imagePath); + + try + { + await using var downloadStream = await HttpClientFactory + .CreateClient(NamedClient.Default) + .GetStreamAsync(url) + .ConfigureAwait(false); + + await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); + imagePath = string.Empty; + } + } + + var manifest = new PluginManifest + { + Category = packageInfo.Category, + Changelog = versionInfo.Changelog ?? string.Empty, + Description = packageInfo.Description, + Id = packageInfo.Id, + Name = packageInfo.Name, + Overview = packageInfo.Overview, + Owner = packageInfo.Owner, + TargetAbi = versionInfo.TargetAbi ?? string.Empty, + Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture), + Version = versionInfo.Version, + Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state. + AutoUpdate = true, + ImagePath = imagePath + }; + + return SaveManifest(manifest, path); + } + + /// <summary> + /// Changes a plugin's load status. + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> instance.</param> + /// <param name="state">The <see cref="PluginStatus"/> of the plugin.</param> + /// <returns>Success of the task.</returns> + private bool ChangePluginState(LocalPlugin plugin, PluginStatus state) + { + if (plugin.Manifest.Status == state || string.IsNullOrEmpty(plugin.Path)) + { + // No need to save as the state hasn't changed. + return true; + } + + plugin.Manifest.Status = state; + return SaveManifest(plugin.Manifest, plugin.Path); + } + + /// <summary> + /// Finds the plugin record using the assembly. + /// </summary> + /// <param name="assembly">The <see cref="Assembly"/> being sought.</param> + /// <returns>The matching record, or null if not found.</returns> + private LocalPlugin? GetPluginByAssembly(Assembly assembly) + { + // Find which plugin it is by the path. + return _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location, StringComparer.Ordinal)); + } + + /// <summary> + /// Creates the instance safe. + /// </summary> + /// <param name="type">The type.</param> + /// <returns>System.Object.</returns> + private IPlugin? CreatePluginInstance(Type type) + { + // Find the record for this plugin. + var plugin = GetPluginByAssembly(type.Assembly); + if (plugin?.Manifest.Status < PluginStatus.Active) + { + return null; + } + + try + { + _logger.LogDebug("Creating instance of {Type}", type); + // _appHost.ServiceProvider is already assigned when we create the plugins + var instance = (IPlugin)ActivatorUtilities.CreateInstance(_appHost.ServiceProvider!, type); + if (plugin == null) + { + // Create a dummy record for the providers. + // TODO: remove this code once all provided have been released as separate plugins. + plugin = new LocalPlugin( + instance.AssemblyFilePath, + true, + new PluginManifest + { + Id = instance.Id, + Status = PluginStatus.Active, + Name = instance.Name, + Version = instance.Version.ToString() + }) + { + Instance = instance + }; + + _plugins.Add(plugin); + + plugin.Manifest.Status = PluginStatus.Active; + } + else + { + plugin.Instance = instance; + var manifest = plugin.Manifest; + var pluginStr = instance.Version.ToString(); + bool changed = false; + if (string.Equals(manifest.Version, pluginStr, StringComparison.Ordinal) + || manifest.Id != instance.Id) + { + // If a plugin without a manifest failed to load due to an external issue (eg config), + // this updates the manifest to the actual plugin values. + manifest.Version = pluginStr; + manifest.Name = plugin.Instance.Name; + manifest.Description = plugin.Instance.Description; + manifest.Id = plugin.Instance.Id; + changed = true; + } + + changed = changed || manifest.Status != PluginStatus.Active; + manifest.Status = PluginStatus.Active; + + if (changed) + { + SaveManifest(manifest, plugin.Path); + } + } + + _logger.LogInformation("Loaded plugin: {PluginName} {PluginVersion}", plugin.Name, plugin.Version); + + return instance; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogError(ex, "Error creating {Type}", type.FullName); + if (plugin != null) + { + if (ChangePluginState(plugin, PluginStatus.Malfunctioned)) + { + _logger.LogInformation("Plugin {Path} has been disabled.", plugin.Path); + return null; + } + } + + _logger.LogDebug("Unable to auto-disable."); + return null; + } + } + + private void UpdatePluginSuperceedStatus(LocalPlugin plugin) + { + if (plugin.Manifest.Status != PluginStatus.Superceded) + { + return; + } + + var predecessor = _plugins.OrderByDescending(p => p.Version) + .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); + if (predecessor != null) + { + return; + } + + plugin.Manifest.Status = PluginStatus.Active; + } + + /// <summary> + /// Attempts to delete a plugin. + /// </summary> + /// <param name="plugin">A <see cref="LocalPlugin"/> instance to delete.</param> + /// <returns>True if successful.</returns> + private bool DeletePlugin(LocalPlugin plugin) + { + // Attempt a cleanup of old folders. + try + { + Directory.Delete(plugin.Path, true); + _logger.LogDebug("Deleted {Path}", plugin.Path); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + return false; + } + + return _plugins.Remove(plugin); + } + + internal LocalPlugin LoadManifest(string dir) + { + Version? version; + PluginManifest? manifest = null; + var metafile = Path.Combine(dir, "meta.json"); + if (File.Exists(metafile)) + { + // Only path where this stays null is when File.ReadAllBytes throws an IOException + byte[] data = null!; + try + { + data = File.ReadAllBytes(metafile); + manifest = JsonSerializer.Deserialize<PluginManifest>(data, _jsonOptions); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error reading file {Path}.", dir); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Error deserializing {Json}.", Encoding.UTF8.GetString(data!)); + } + + if (manifest != null) + { + if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) + { + targetAbi = _minimumVersion; + } + + if (!Version.TryParse(manifest.Version, out version)) + { + manifest.Version = _minimumVersion.ToString(); + } + + return new LocalPlugin(dir, _appVersion >= targetAbi, manifest); + } + } + + // No metafile, so lets see if the folder is versioned. + // TODO: Phase this support out in future versions. + metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1]; + int versionIndex = dir.LastIndexOf('_'); + if (versionIndex != -1) + { + // Get the version number from the filename if possible. + metafile = Path.GetFileName(dir[..versionIndex]) ?? dir[..versionIndex]; + version = Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version? parsedVersion) ? parsedVersion : _appVersion; + } + else + { + // Un-versioned folder - Add it under the path name and version it suitable for this instance. + version = _appVersion; + } + + // Auto-create a plugin manifest, so we can disable it, if it fails to load. + manifest = new PluginManifest + { + Status = PluginStatus.Active, + Name = metafile, + AutoUpdate = false, + Id = metafile.GetMD5(), + TargetAbi = _appVersion.ToString(), + Version = version.ToString() + }; + + return new LocalPlugin(dir, true, manifest); + } + + /// <summary> + /// Gets the list of local plugins. + /// </summary> + /// <returns>Enumerable of local plugins.</returns> + private IEnumerable<LocalPlugin> DiscoverPlugins() + { + var versions = new List<LocalPlugin>(); + + if (!Directory.Exists(_pluginsPath)) + { + // Plugin path doesn't exist, don't try to enumerate sub-folders. + return Enumerable.Empty<LocalPlugin>(); + } + + var directories = Directory.EnumerateDirectories(_pluginsPath, "*.*", SearchOption.TopDirectoryOnly); + foreach (var dir in directories) + { + versions.Add(LoadManifest(dir)); + } + + string lastName = string.Empty; + versions.Sort(LocalPlugin.Compare); + // Traverse backwards through the list. + // The first item will be the latest version. + for (int x = versions.Count - 1; x >= 0; x--) + { + var entry = versions[x]; + if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) + { + entry.DllFiles = Directory.GetFiles(entry.Path, "*.dll", SearchOption.AllDirectories); + if (entry.IsEnabledAndSupported) + { + lastName = entry.Name; + continue; + } + } + + if (string.IsNullOrEmpty(lastName)) + { + continue; + } + + var manifest = entry.Manifest; + var cleaned = false; + var path = entry.Path; + if (_config.RemoveOldPlugins) + { + // Attempt a cleanup of old folders. + try + { + _logger.LogDebug("Deleting {Path}", path); + Directory.Delete(path, true); + cleaned = true; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception e) +#pragma warning restore CA1031 // Do not catch general exception types + { + _logger.LogWarning(e, "Unable to delete {Path}", path); + } + + if (cleaned) + { + versions.RemoveAt(x); + } + else + { + if (manifest == null) + { + _logger.LogWarning("Unable to disable plugin {Path}", entry.Path); + continue; + } + + ChangePluginState(entry, PluginStatus.Deleted); + } + } + } + + // Only want plugin folders which have files. + return versions.Where(p => p.DllFiles.Count != 0); + } + + /// <summary> + /// Changes the status of the other versions of the plugin to "Superceded". + /// </summary> + /// <param name="plugin">The <see cref="LocalPlugin"/> that's master.</param> + private void ProcessAlternative(LocalPlugin plugin) + { + // Detect whether there is another version of this plugin that needs disabling. + var previousVersion = _plugins.OrderByDescending(p => p.Version) + .FirstOrDefault( + p => p.Id.Equals(plugin.Id) + && p.IsEnabledAndSupported + && p.Version != plugin.Version); + + if (previousVersion == null) + { + // This value is memory only - so that the web will show restart required. + plugin.Manifest.Status = PluginStatus.Restart; + return; + } + + if (plugin.Manifest.Status == PluginStatus.Active && !ChangePluginState(previousVersion, PluginStatus.Superceded)) + { + _logger.LogError("Unable to enable version {Version} of {Name}", previousVersion.Version, previousVersion.Name); + } + else if (plugin.Manifest.Status == PluginStatus.Superceded && !ChangePluginState(previousVersion, PluginStatus.Active)) + { + _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name); + } + + // This value is memory only - so that the web will show restart required. + plugin.Manifest.Status = PluginStatus.Restart; + } + } +} 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/QuickConnect/QuickConnectManager.cs b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs new file mode 100644 index 000000000..898cbedbb --- /dev/null +++ b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.QuickConnect; +using MediaBrowser.Controller.Security; +using MediaBrowser.Model.QuickConnect; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.QuickConnect +{ + /// <summary> + /// Quick connect implementation. + /// </summary> + public class QuickConnectManager : IQuickConnect, IDisposable + { + /// <summary> + /// The name of internal access tokens. + /// </summary> + private const string TokenName = "QuickConnect"; + + /// <summary> + /// The length of user facing codes. + /// </summary> + private const int CodeLength = 6; + + /// <summary> + /// The time (in minutes) that the quick connect token is valid. + /// </summary> + private const int Timeout = 10; + + private readonly RNGCryptoServiceProvider _rng = new(); + private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new(); + + private readonly IServerConfigurationManager _config; + private readonly ILogger<QuickConnectManager> _logger; + private readonly IServerApplicationHost _appHost; + private readonly IAuthenticationRepository _authenticationRepository; + + /// <summary> + /// Initializes a new instance of the <see cref="QuickConnectManager"/> class. + /// Should only be called at server startup when a singleton is created. + /// </summary> + /// <param name="config">Configuration.</param> + /// <param name="logger">Logger.</param> + /// <param name="appHost">Application host.</param> + /// <param name="authenticationRepository">Authentication repository.</param> + public QuickConnectManager( + IServerConfigurationManager config, + ILogger<QuickConnectManager> logger, + IServerApplicationHost appHost, + IAuthenticationRepository authenticationRepository) + { + _config = config; + _logger = logger; + _appHost = appHost; + _authenticationRepository = authenticationRepository; + } + + /// <inheritdoc /> + public bool IsEnabled => _config.Configuration.QuickConnectAvailable; + + /// <summary> + /// Assert that quick connect is currently active and throws an exception if it is not. + /// </summary> + private void AssertActive() + { + if (!IsEnabled) + { + throw new AuthenticationException("Quick connect is not active on this server"); + } + } + + /// <inheritdoc/> + public QuickConnectResult TryConnect() + { + AssertActive(); + ExpireRequests(); + + var secret = GenerateSecureRandom(); + var code = GenerateCode(); + var result = new QuickConnectResult(secret, code, DateTime.UtcNow); + + _currentRequests[code] = result; + return result; + } + + /// <inheritdoc/> + public QuickConnectResult CheckRequestStatus(string secret) + { + AssertActive(); + ExpireRequests(); + + string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First(); + + if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result)) + { + throw new ResourceNotFoundException("Unable to find request with provided secret"); + } + + return result; + } + + /// <summary> + /// Generates a short code to display to the user to uniquely identify this request. + /// </summary> + /// <returns>A short, unique alphanumeric string.</returns> + private string GenerateCode() + { + Span<byte> raw = stackalloc byte[4]; + + int min = (int)Math.Pow(10, CodeLength - 1); + int max = (int)Math.Pow(10, CodeLength); + + uint scale = uint.MaxValue; + while (scale == uint.MaxValue) + { + _rng.GetBytes(raw); + scale = BitConverter.ToUInt32(raw); + } + + int code = (int)(min + ((max - min) * (scale / (double)uint.MaxValue))); + return code.ToString(CultureInfo.InvariantCulture); + } + + /// <inheritdoc/> + public bool AuthorizeRequest(Guid userId, string code) + { + AssertActive(); + ExpireRequests(); + + if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result)) + { + throw new ResourceNotFoundException("Unable to find request"); + } + + if (result.Authenticated) + { + throw new InvalidOperationException("Request is already authorized"); + } + + var token = Guid.NewGuid(); + result.Authentication = token; + + // Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated. + result.DateAdded = DateTime.Now.Add(TimeSpan.FromMinutes(1)); + + _authenticationRepository.Create(new AuthenticationInfo + { + AppName = TokenName, + AccessToken = token.ToString("N", CultureInfo.InvariantCulture), + DateCreated = DateTime.UtcNow, + DeviceId = _appHost.SystemId, + DeviceName = _appHost.FriendlyName, + AppVersion = _appHost.ApplicationVersionString, + UserId = userId + }); + + _logger.LogDebug("Authorizing device with code {Code} to login as user {userId}", code, userId); + + return true; + } + + /// <summary> + /// Dispose. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose. + /// </summary> + /// <param name="disposing">Dispose unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _rng.Dispose(); + } + } + + private string GenerateSecureRandom(int length = 32) + { + Span<byte> bytes = stackalloc byte[length]; + _rng.GetBytes(bytes); + + return Convert.ToHexString(bytes); + } + + /// <summary> + /// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired. + /// </summary> + /// <param name="expireAll">If true, all requests will be expired.</param> + private void ExpireRequests(bool expireAll = false) + { + // All requests before this timestamp have expired + var minTime = DateTime.UtcNow.AddMinutes(-Timeout); + + // Expire stale connection requests + foreach (var (_, currentRequest) in _currentRequests) + { + if (expireAll || currentRequest.DateAdded < minTime) + { + var code = currentRequest.Code; + _logger.LogDebug("Removing expired request {Code}", code); + + if (!_currentRequests.TryRemove(code, out _)) + { + _logger.LogWarning("Request {Code} already expired", code); + } + } + } + } + } +} diff --git a/Emby.Server.Implementations/ResourceFileManager.cs b/Emby.Server.Implementations/ResourceFileManager.cs deleted file mode 100644 index 22fc62293..000000000 --- a/Emby.Server.Implementations/ResourceFileManager.cs +++ /dev/null @@ -1,45 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations -{ - public class ResourceFileManager : IResourceFileManager - { - private readonly IFileSystem _fileSystem; - private readonly ILogger<ResourceFileManager> _logger; - - public ResourceFileManager(ILogger<ResourceFileManager> logger, IFileSystem fileSystem) - { - _logger = logger; - _fileSystem = fileSystem; - } - - public string GetResourcePath(string basePath, string virtualPath) - { - var fullPath = Path.Combine(basePath, virtualPath.Replace('/', Path.DirectorySeparatorChar)); - - try - { - fullPath = Path.GetFullPath(fullPath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving full path"); - } - - // Don't allow file system access outside of the source folder - if (!_fileSystem.ContainsSubPath(basePath, fullPath)) - { - throw new SecurityException("Access denied"); - } - - return fullPath; - } - } -} diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index bc01f9543..b34325041 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -1,16 +1,19 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Globalization; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Progress; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -21,11 +24,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 +68,16 @@ namespace Emby.Server.Implementations.ScheduledTasks private string _id; /// <summary> + /// The options for the json Serializer. + /// </summary> + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + /// <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 +90,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 +107,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 +115,6 @@ namespace Emby.Server.Implementations.ScheduledTasks ScheduledTask = scheduledTask; _applicationPaths = applicationPaths; _taskManager = taskManager; - _jsonSerializer = jsonSerializer; _logger = logger; InitTriggerEvents(); @@ -148,13 +144,21 @@ namespace Emby.Server.Implementations.ScheduledTasks { if (File.Exists(path)) { - try + var bytes = File.ReadAllBytes(path); + if (bytes.Length > 0) { - _lastExecutionResult = _jsonSerializer.DeserializeFromFile<TaskResult>(path); + try + { + _lastExecutionResult = JsonSerializer.Deserialize<TaskResult>(bytes, _jsonOptions); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Error deserializing {File}", path); + } } - catch (Exception ex) + else { - _logger.LogError(ex, "Error deserializing {File}", path); + _logger.LogDebug("Scheduled Task history file {Path} is empty. Skipping deserialization.", path); } } @@ -174,7 +178,9 @@ namespace Emby.Server.Implementations.ScheduledTasks lock (_lastExecutionResultSyncLock) { - _jsonSerializer.SerializeToFile(value, path); + using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + using Utf8JsonWriter jsonStream = new Utf8JsonWriter(createStream); + JsonSerializer.Serialize(jsonStream, value, _jsonOptions); } } } @@ -297,12 +303,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { get { - if (_id == null) - { - _id = ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture); - } - - return _id; + return _id ??= ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture); } } @@ -344,9 +345,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var trigger = (ITaskTrigger)sender; - var configurableTask = ScheduledTask as IConfigurableScheduledTask; - - if (configurableTask != null && !configurableTask.IsEnabled) + if (ScheduledTask is IConfigurableScheduledTask configurableTask && !configurableTask.IsEnabled) { return; } @@ -537,7 +536,8 @@ namespace Emby.Server.Implementations.ScheduledTasks TaskTriggerInfo[] list = null; if (File.Exists(path)) { - list = _jsonSerializer.DeserializeFromFile<TaskTriggerInfo[]>(path); + var bytes = File.ReadAllBytes(path); + list = JsonSerializer.Deserialize<TaskTriggerInfo[]>(bytes, _jsonOptions); } // Return defaults if file doesn't exist. @@ -572,8 +572,9 @@ namespace Emby.Server.Implementations.ScheduledTasks var path = GetConfigurationFilePath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); - - _jsonSerializer.SerializeToFile(triggers, path); + using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + using Utf8JsonWriter jsonWriter = new Utf8JsonWriter(createStream); + JsonSerializer.Serialize(jsonWriter, triggers, _jsonOptions); } /// <summary> @@ -653,7 +654,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { _logger.LogInformation(Name + ": Waiting on Task"); - var exited = Task.WaitAll(new[] { task }, 2000); + var exited = task.Wait(2000); if (exited) { @@ -703,21 +704,17 @@ namespace Emby.Server.Implementations.ScheduledTasks MaxRuntimeTicks = info.MaxRuntimeTicks }; - if (info.Type.Equals(typeof(DailyTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(DailyTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.TimeOfDayTicks.HasValue) { throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info)); } - return new DailyTrigger - { - TimeOfDay = TimeSpan.FromTicks(info.TimeOfDayTicks.Value), - TaskOptions = options - }; + return new DailyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), options); } - if (info.Type.Equals(typeof(WeeklyTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(WeeklyTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.TimeOfDayTicks.HasValue) { @@ -729,31 +726,22 @@ namespace Emby.Server.Implementations.ScheduledTasks throw new ArgumentException("Info did not contain a DayOfWeek.", nameof(info)); } - return new WeeklyTrigger - { - TimeOfDay = TimeSpan.FromTicks(info.TimeOfDayTicks.Value), - DayOfWeek = info.DayOfWeek.Value, - TaskOptions = options - }; + return new WeeklyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), info.DayOfWeek.Value, options); } - if (info.Type.Equals(typeof(IntervalTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(IntervalTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.IntervalTicks.HasValue) { throw new ArgumentException("Info did not contain a IntervalTicks.", nameof(info)); } - return new IntervalTrigger - { - Interval = TimeSpan.FromTicks(info.IntervalTicks.Value), - TaskOptions = options - }; + return new IntervalTrigger(TimeSpan.FromTicks(info.IntervalTicks.Value), options); } - if (info.Type.Equals(typeof(StartupTrigger).Name, StringComparison.OrdinalIgnoreCase)) + if (info.Type.Equals(nameof(StartupTrigger), StringComparison.OrdinalIgnoreCase)) { - return new StartupTrigger(); + return new StartupTrigger(options); } throw new ArgumentException("Unrecognized trigger type: " + info.Type); diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 6f81bf49b..4f0df75bf 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -7,7 +9,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 +20,7 @@ namespace Emby.Server.Implementations.ScheduledTasks public class TaskManager : ITaskManager { public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + public event EventHandler<TaskCompletionEventArgs> TaskCompleted; /// <summary> @@ -33,7 +35,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 +42,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>(); @@ -136,7 +134,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = scheduledTask.ScheduledTask.GetType(); - _logger.LogInformation("Queueing task {0}", type.Name); + _logger.LogInformation("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -176,7 +174,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = task.ScheduledTask.GetType(); - _logger.LogInformation("Queueing task {0}", type.Name); + _logger.LogInformation("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -196,7 +194,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/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index 8439f8a99..b764a139c 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -12,9 +12,9 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Globalization; namespace Emby.Server.Implementations.ScheduledTasks { @@ -55,9 +55,19 @@ namespace Emby.Server.Implementations.ScheduledTasks _localization = localization; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + /// <inheritdoc /> + public string Key => "RefreshChapterImages"; + + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] @@ -106,7 +116,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { previouslyFailedImages = File.ReadAllText(failHistoryPath) - .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + .Split('|', StringSplitOptions.RemoveEmptyEntries) .ToList(); } catch (IOException) @@ -140,10 +150,12 @@ namespace Emby.Server.Implementations.ScheduledTasks previouslyFailedImages.Add(key); var parentPath = Path.GetDirectoryName(failHistoryPath); + if (parentPath != null) + { + Directory.CreateDirectory(parentPath); + } - Directory.CreateDirectory(parentPath); - - string text = string.Join("|", previouslyFailedImages); + string text = string.Join('|', previouslyFailedImages); File.WriteAllText(failHistoryPath, text); } @@ -160,26 +172,5 @@ namespace Emby.Server.Implementations.ScheduledTasks } } } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); - - /// <inheritdoc /> - public string Key => "RefreshChapterImages"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs new file mode 100644 index 000000000..19600b1e6 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks +{ + /// <summary> + /// Deletes old activity log entries. + /// </summary> + public class CleanActivityLogTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly ILocalizationManager _localization; + private readonly IActivityManager _activityManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanActivityLogTask"/> class. + /// </summary> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public CleanActivityLogTask( + ILocalizationManager localization, + IActivityManager activityManager, + IServerConfigurationManager serverConfigurationManager) + { + _localization = localization; + _activityManager = activityManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanActivityLog"); + + /// <inheritdoc /> + public string Key => "CleanActivityLog"; + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskCleanActivityLogDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <inheritdoc /> + public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + { + var retentionDays = _serverConfigurationManager.Configuration.ActivityLogRetentionDays; + if (!retentionDays.HasValue || retentionDays <= 0) + { + throw new Exception($"Activity Log Retention days must be at least 0. Currently: {retentionDays}"); + } + + var startDate = DateTime.UtcNow.AddDays(-retentionDays.Value); + return _activityManager.CleanAsync(startDate); + } + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return Enumerable.Empty<TaskTriggerInfo>(); + } + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs index e29fcfb5f..692d1667d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs @@ -5,10 +5,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -using MediaBrowser.Model.Globalization; namespace Emby.Server.Implementations.ScheduledTasks.Tasks { @@ -21,10 +21,8 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// Gets or sets the application paths. /// </summary> /// <value>The application paths.</value> - private IApplicationPaths ApplicationPaths { get; set; } - + private readonly IApplicationPaths _applicationPaths; private readonly ILogger<DeleteCacheFileTask> _logger; - private readonly IFileSystem _fileSystem; private readonly ILocalizationManager _localization; @@ -37,22 +35,43 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks IFileSystem fileSystem, ILocalizationManager localization) { - ApplicationPaths = appPaths; + _applicationPaths = appPaths; _logger = logger; _fileSystem = fileSystem; _localization = localization; } + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanCache"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskCleanCacheDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => "DeleteCacheFiles"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + /// <summary> /// Creates the triggers that define when the task will run. /// </summary> /// <returns>IEnumerable{BaseTaskTrigger}.</returns> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { - return new[] { - + return new[] + { // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks} + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } @@ -68,7 +87,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks try { - DeleteCacheFilesFromDirectory(cancellationToken, ApplicationPaths.CachePath, minDateModified, progress); + DeleteCacheFilesFromDirectory(cancellationToken, _applicationPaths.CachePath, minDateModified, progress); } catch (DirectoryNotFoundException) { @@ -81,7 +100,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks try { - DeleteCacheFilesFromDirectory(cancellationToken, ApplicationPaths.TempDirectory, minDateModified, progress); + DeleteCacheFilesFromDirectory(cancellationToken, _applicationPaths.TempDirectory, minDateModified, progress); } catch (DirectoryNotFoundException) { @@ -91,7 +110,6 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks return Task.CompletedTask; } - /// <summary> /// Deletes the cache files from directory with a last write time less than a given date. /// </summary> @@ -164,26 +182,5 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks _logger.LogError(ex, "Error deleting file {path}", path); } } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskCleanCache"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskCleanCacheDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); - - /// <inheritdoc /> - public string Key => "DeleteCacheFiles"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs index 54e18eaea..fedb5deb0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs @@ -65,7 +65,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { return new[] { - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks} + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } @@ -80,10 +80,11 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks // Delete log files more than n days old var minDateModified = DateTime.UtcNow.AddDays(-_configurationManager.CommonConfiguration.LogFileRetentionDays); - // Only delete the .txt log files, the *.log files created by serilog get managed by itself - var filesToDelete = _fileSystem.GetFiles(_configurationManager.CommonApplicationPaths.LogDirectoryPath, new[] { ".txt" }, true, true) - .Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) - .ToList(); + // Only delete files that serilog doesn't manage (anything that doesn't start with 'log_' + var filesToDelete = _fileSystem.GetFiles(_configurationManager.CommonApplicationPaths.LogDirectoryPath, true) + .Where(f => !f.Name.StartsWith("log_", StringComparison.Ordinal) + && _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) + .ToList(); var index = 0; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs index 691408167..b13fc7fc6 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs @@ -5,10 +5,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -using MediaBrowser.Model.Globalization; namespace Emby.Server.Implementations.ScheduledTasks.Tasks { @@ -23,8 +23,12 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks private readonly ILocalizationManager _localization; /// <summary> - /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask" /> class. + /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class. /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{DeleteTranscodeFileTask}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> public DeleteTranscodeFileTask( ILogger<DeleteTranscodeFileTask> logger, IFileSystem fileSystem, @@ -37,11 +41,42 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks _localization = localization; } + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanTranscode"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => "DeleteTranscodeFiles"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + /// <summary> /// Creates the triggers that define when the task will run. /// </summary> /// <returns>IEnumerable{BaseTaskTrigger}.</returns> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => new List<TaskTriggerInfo>(); + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } + }; + } /// <summary> /// Returns the task to be executed. @@ -131,26 +166,5 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks _logger.LogError(ex, "Error deleting file {path}", path); } } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskCleanTranscode"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); - - /// <inheritdoc /> - public string Key => "DeleteTranscodeFiles"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => false; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs new file mode 100644 index 000000000..1ad1d0f50 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks +{ + /// <summary> + /// Optimizes Jellyfin's database by issuing a VACUUM command. + /// </summary> + public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly ILogger<OptimizeDatabaseTask> _logger; + private readonly ILocalizationManager _localization; + private readonly JellyfinDbProvider _provider; + + /// <summary> + /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. + /// </summary> + public OptimizeDatabaseTask( + ILogger<OptimizeDatabaseTask> logger, + ILocalizationManager localization, + JellyfinDbProvider provider) + { + _logger = logger; + _localization = localization; + _provider = provider; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskOptimizeDatabase"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskOptimizeDatabaseDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => "OptimizeDatabaseTask"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + // Every so often + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + }; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + { + _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); + + try + { + using var context = _provider.CreateContext(); + if (context.Database.IsSqlite()) + { + context.Database.ExecuteSqlRaw("PRAGMA optimize"); + context.Database.ExecuteSqlRaw("VACUUM"); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + else + { + _logger.LogInformation("This database doesn't support optimization"); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error while optimizing jellyfin.db"); + } + + return Task.CompletedTask; + } + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index c384cf4bb..57d294a40 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.ScheduledTasks { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index 7388086fb..11a5fb79f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -4,13 +4,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Updates; -using MediaBrowser.Model.Net; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -using MediaBrowser.Model.Globalization; namespace Emby.Server.Implementations.ScheduledTasks { @@ -34,6 +34,27 @@ namespace Emby.Server.Implementations.ScheduledTasks _localization = localization; } + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskUpdatePlugins"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskUpdatePluginsDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksApplicationCategory"); + + /// <inheritdoc /> + public string Key => "PluginUpdates"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + /// <summary> /// Creates the triggers that define when the task will run. /// </summary> @@ -80,7 +101,7 @@ namespace Emby.Server.Implementations.ScheduledTasks throw; } } - catch (HttpException ex) + catch (HttpRequestException ex) { _logger.LogError(ex, "Error downloading {0}", package.Name); } @@ -98,26 +119,5 @@ namespace Emby.Server.Implementations.ScheduledTasks progress.Report(100); } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskUpdatePlugins"); - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskUpdatePluginsDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksApplicationCategory"); - - /// <inheritdoc /> - public string Key => "PluginUpdates"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs index e470adcf4..51b620404 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs @@ -6,8 +6,8 @@ using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.ScheduledTasks { diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs index eb628ec5f..29ab6a73d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs @@ -8,24 +8,31 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Represents a task trigger that fires everyday. /// </summary> - public class DailyTrigger : ITaskTrigger + public sealed class DailyTrigger : ITaskTrigger { + private readonly TimeSpan _timeOfDay; + private Timer? _timer; + /// <summary> - /// Get the time of day to trigger the task to run. + /// Initializes a new instance of the <see cref="DailyTrigger"/> class. /// </summary> - /// <value>The time of day.</value> - public TimeSpan TimeOfDay { get; set; } + /// <param name="timeofDay">The time of day to trigger the task to run.</param> + /// <param name="taskOptions">The options of this task.</param> + public DailyTrigger(TimeSpan timeofDay, TaskOptions taskOptions) + { + _timeOfDay = timeofDay; + TaskOptions = taskOptions; + } /// <summary> - /// Gets or sets the options of this task. + /// Occurs when [triggered]. /// </summary> - public TaskOptions TaskOptions { get; set; } + public event EventHandler<EventArgs>? Triggered; /// <summary> - /// Gets or sets the timer. + /// Gets the options of this task. /// </summary> - /// <value>The timer.</value> - private Timer Timer { get; set; } + public TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. @@ -40,14 +47,14 @@ namespace Emby.Server.Implementations.ScheduledTasks var now = DateTime.Now; - var triggerDate = now.TimeOfDay > TimeOfDay ? now.Date.AddDays(1) : now.Date; - triggerDate = triggerDate.Add(TimeOfDay); + var triggerDate = now.TimeOfDay > _timeOfDay ? now.Date.AddDays(1) : now.Date; + triggerDate = triggerDate.Add(_timeOfDay); var dueTime = triggerDate - now; - logger.LogInformation("Daily trigger for {Task} set to fire at {TriggerDate:g}, which is {DueTime:g} from now.", taskName, triggerDate, dueTime); + logger.LogInformation("Daily trigger for {Task} set to fire at {TriggerDate:yyyy-MM-dd HH:mm:ss.fff zzz}, which is {DueTime:c} from now.", taskName, triggerDate, dueTime); - Timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } /// <summary> @@ -63,18 +70,10 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void DisposeTimer() { - if (Timer != null) - { - Timer.Dispose(); - } + _timer?.Dispose(); } /// <summary> - /// Occurs when [triggered]. - /// </summary> - public event EventHandler<EventArgs> Triggered; - - /// <summary> /// Called when [triggered]. /// </summary> private void OnTriggered() diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs index 247a6785a..30568e809 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs @@ -9,26 +9,32 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Represents a task trigger that runs repeatedly on an interval. /// </summary> - public class IntervalTrigger : ITaskTrigger + public sealed class IntervalTrigger : ITaskTrigger { + private readonly TimeSpan _interval; + private DateTime _lastStartDate; + private Timer? _timer; + /// <summary> - /// Gets or sets the interval. + /// Initializes a new instance of the <see cref="IntervalTrigger"/> class. /// </summary> - /// <value>The interval.</value> - public TimeSpan Interval { get; set; } + /// <param name="interval">The interval.</param> + /// <param name="taskOptions">The options of this task.</param> + public IntervalTrigger(TimeSpan interval, TaskOptions taskOptions) + { + _interval = interval; + TaskOptions = taskOptions; + } /// <summary> - /// Gets or sets the options of this task. + /// Occurs when [triggered]. /// </summary> - public TaskOptions TaskOptions { get; set; } + public event EventHandler<EventArgs>? Triggered; /// <summary> - /// Gets or sets the timer. + /// Gets the options of this task. /// </summary> - /// <value>The timer.</value> - private Timer Timer { get; set; } - - private DateTime _lastStartDate; + public TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. @@ -50,7 +56,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } else { - triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate }.Max().Add(Interval); + triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate }.Max().Add(_interval); } if (DateTime.UtcNow > triggerDate) @@ -66,7 +72,7 @@ namespace Emby.Server.Implementations.ScheduledTasks dueTime = maxDueTime; } - Timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(state => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } /// <summary> @@ -82,18 +88,10 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void DisposeTimer() { - if (Timer != null) - { - Timer.Dispose(); - } + _timer?.Dispose(); } /// <summary> - /// Occurs when [triggered]. - /// </summary> - public event EventHandler<EventArgs> Triggered; - - /// <summary> /// Called when [triggered]. /// </summary> private void OnTriggered() diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs index 96e5d8897..18b9a8b75 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs @@ -10,21 +10,30 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Class StartupTaskTrigger. /// </summary> - public class StartupTrigger : ITaskTrigger + public sealed class StartupTrigger : ITaskTrigger { - public int DelayMs { get; set; } + public const int DelayMs = 3000; /// <summary> - /// Gets or sets the options of this task. + /// Initializes a new instance of the <see cref="StartupTrigger"/> class. /// </summary> - public TaskOptions TaskOptions { get; set; } - - public StartupTrigger() + /// <param name="taskOptions">The options of this task.</param> + public StartupTrigger(TaskOptions taskOptions) { - DelayMs = 3000; + TaskOptions = taskOptions; } /// <summary> + /// Occurs when [triggered]. + /// </summary> + public event EventHandler<EventArgs>? Triggered; + + /// <summary> + /// Gets the options of this task. + /// </summary> + public TaskOptions TaskOptions { get; } + + /// <summary> /// Stars waiting for the trigger action. /// </summary> /// <param name="lastResult">The last result.</param> @@ -49,19 +58,11 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Occurs when [triggered]. - /// </summary> - public event EventHandler<EventArgs> Triggered; - - /// <summary> /// Called when [triggered]. /// </summary> private void OnTriggered() { - if (Triggered != null) - { - Triggered(this, EventArgs.Empty); - } + Triggered?.Invoke(this, EventArgs.Empty); } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs index 4f1bf5c19..36ae190b0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs @@ -8,30 +8,34 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Represents a task trigger that fires on a weekly basis. /// </summary> - public class WeeklyTrigger : ITaskTrigger + public sealed class WeeklyTrigger : ITaskTrigger { - /// <summary> - /// Get the time of day to trigger the task to run. - /// </summary> - /// <value>The time of day.</value> - public TimeSpan TimeOfDay { get; set; } + private readonly TimeSpan _timeOfDay; + private readonly DayOfWeek _dayOfWeek; + private Timer? _timer; /// <summary> - /// Gets or sets the day of week. + /// Initializes a new instance of the <see cref="WeeklyTrigger"/> class. /// </summary> - /// <value>The day of week.</value> - public DayOfWeek DayOfWeek { get; set; } + /// <param name="timeofDay">The time of day to trigger the task to run.</param> + /// <param name="dayOfWeek">The day of week.</param> + /// <param name="taskOptions">The options of this task.</param> + public WeeklyTrigger(TimeSpan timeofDay, DayOfWeek dayOfWeek, TaskOptions taskOptions) + { + _timeOfDay = timeofDay; + _dayOfWeek = dayOfWeek; + TaskOptions = taskOptions; + } /// <summary> - /// Gets or sets the options of this task. + /// Occurs when [triggered]. /// </summary> - public TaskOptions TaskOptions { get; set; } + public event EventHandler<EventArgs>? Triggered; /// <summary> - /// Gets or sets the timer. + /// Gets the options of this task. /// </summary> - /// <value>The timer.</value> - private Timer Timer { get; set; } + public TaskOptions TaskOptions { get; } /// <summary> /// Stars waiting for the trigger action. @@ -46,7 +50,7 @@ namespace Emby.Server.Implementations.ScheduledTasks var triggerDate = GetNextTriggerDateTime(); - Timer = new Timer(state => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(state => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1)); } /// <summary> @@ -58,22 +62,22 @@ namespace Emby.Server.Implementations.ScheduledTasks var now = DateTime.Now; // If it's on the same day - if (now.DayOfWeek == DayOfWeek) + if (now.DayOfWeek == _dayOfWeek) { // It's either later today, or a week from now - return now.TimeOfDay < TimeOfDay ? now.Date.Add(TimeOfDay) : now.Date.AddDays(7).Add(TimeOfDay); + return now.TimeOfDay < _timeOfDay ? now.Date.Add(_timeOfDay) : now.Date.AddDays(7).Add(_timeOfDay); } var triggerDate = now.Date; // Walk the date forward until we get to the trigger day - while (triggerDate.DayOfWeek != DayOfWeek) + while (triggerDate.DayOfWeek != _dayOfWeek) { triggerDate = triggerDate.AddDays(1); } // Return the trigger date plus the time offset - return triggerDate.Add(TimeOfDay); + return triggerDate.Add(_timeOfDay); } /// <summary> @@ -89,26 +93,15 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void DisposeTimer() { - if (Timer != null) - { - Timer.Dispose(); - } + _timer?.Dispose(); } /// <summary> - /// Occurs when [triggered]. - /// </summary> - public event EventHandler<EventArgs> Triggered; - - /// <summary> /// Called when [triggered]. /// </summary> private void OnTriggered() { - if (Triggered != null) - { - Triggered(this, EventArgs.Empty); - } + Triggered?.Invoke(this, EventArgs.Empty); } } } diff --git a/Emby.Server.Implementations/Security/AuthenticationRepository.cs b/Emby.Server.Implementations/Security/AuthenticationRepository.cs index 4dfadc703..e8eac315b 100644 --- a/Emby.Server.Implementations/Security/AuthenticationRepository.cs +++ b/Emby.Server.Implementations/Security/AuthenticationRepository.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -54,7 +56,8 @@ namespace Emby.Server.Implementations.Security { if (tableNewlyCreated && TableExists(connection, "AccessTokens")) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { var existingColumnNames = GetColumnNames(db, "AccessTokens"); @@ -88,7 +91,8 @@ namespace Emby.Server.Implementations.Security using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { using (var statement = db.PrepareStatement("insert into Tokens (AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, IsActive, DateCreated, DateLastActivity) values (@AccessToken, @DeviceId, @AppName, @AppVersion, @DeviceName, @UserId, @UserName, @IsActive, @DateCreated, @DateLastActivity)")) { @@ -119,7 +123,8 @@ namespace Emby.Server.Implementations.Security using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { using (var statement = db.PrepareStatement("Update Tokens set AccessToken=@AccessToken, DeviceId=@DeviceId, AppName=@AppName, AppVersion=@AppVersion, DeviceName=@DeviceName, UserId=@UserId, UserName=@UserName, DateCreated=@DateCreated, DateLastActivity=@DateLastActivity where Id=@Id")) { @@ -151,7 +156,8 @@ namespace Emby.Server.Implementations.Security using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { using (var statement = db.PrepareStatement("Delete from Tokens where Id=@Id")) { @@ -257,8 +263,7 @@ namespace Emby.Server.Implementations.Security connection.RunInTransaction( db => { - var statements = PrepareAll(db, statementTexts) - .ToList(); + var statements = PrepareAll(db, statementTexts); using (var statement = statements[0]) { @@ -282,11 +287,11 @@ namespace Emby.Server.Implementations.Security ReadTransactionMode); } - result.Items = list.ToArray(); + result.Items = list; return result; } - private static AuthenticationInfo Get(IReadOnlyList<IResultSetValue> reader) + private static AuthenticationInfo Get(IReadOnlyList<ResultSetValue> reader) { var info = new AuthenticationInfo { @@ -294,50 +299,49 @@ namespace Emby.Server.Implementations.Security AccessToken = reader[1].ToString() }; - if (reader[2].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(2, out var deviceId)) { - info.DeviceId = reader[2].ToString(); + info.DeviceId = deviceId; } - if (reader[3].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(3, out var appName)) { - info.AppName = reader[3].ToString(); + info.AppName = appName; } - if (reader[4].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(4, out var appVersion)) { - info.AppVersion = reader[4].ToString(); + info.AppVersion = appVersion; } - if (reader[5].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(6, out var userId)) { - info.DeviceName = reader[5].ToString(); + info.UserId = new Guid(userId); } - if (reader[6].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(7, out var userName)) { - info.UserId = new Guid(reader[6].ToString()); - } - - if (reader[7].SQLiteType != SQLiteType.Null) - { - info.UserName = reader[7].ToString(); + info.UserName = userName; } info.DateCreated = reader[8].ReadDateTime(); - if (reader[9].SQLiteType != SQLiteType.Null) + if (reader.TryReadDateTime(9, out var dateLastActivity)) { - info.DateLastActivity = reader[9].ReadDateTime(); + info.DateLastActivity = dateLastActivity; } else { info.DateLastActivity = info.DateCreated; } - if (reader[10].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(10, out var customName)) { - info.DeviceName = reader[10].ToString(); + info.DeviceName = customName; + } + else if (reader.TryGetString(5, out var deviceName)) + { + info.DeviceName = deviceName; } return info; @@ -347,7 +351,8 @@ namespace Emby.Server.Implementations.Security { using (var connection = GetConnection(true)) { - return connection.RunInTransaction(db => + return connection.RunInTransaction( + db => { using (var statement = base.PrepareStatement(db, "select CustomName from Devices where Id=@DeviceId")) { @@ -357,9 +362,9 @@ namespace Emby.Server.Implementations.Security foreach (var row in statement.ExecuteQuery()) { - if (row[0].SQLiteType != SQLiteType.Null) + if (row.TryGetString(0, out var customName)) { - result.CustomName = row[0].ToString(); + result.CustomName = customName; } } @@ -378,7 +383,8 @@ namespace Emby.Server.Implementations.Security using (var connection = GetConnection()) { - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { using (var statement = db.PrepareStatement("replace into devices (Id, CustomName, Capabilities) VALUES (@Id, @CustomName, (Select Capabilities from Devices where Id=@Id))")) { 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/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index 27024e4e1..5ff73de81 100644 --- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -19,7 +19,10 @@ namespace Emby.Server.Implementations.Serialization new ConcurrentDictionary<string, XmlSerializer>(); private static XmlSerializer GetSerializer(Type type) - => _serializers.GetOrAdd(type.FullName, _ => new XmlSerializer(type)); + => _serializers.GetOrAdd( + type.FullName ?? throw new ArgumentException($"Invalid type {type}."), + (_, t) => new XmlSerializer(t), + type); /// <summary> /// Serializes to writer. @@ -38,7 +41,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="type">The type.</param> /// <param name="stream">The stream.</param> /// <returns>System.Object.</returns> - public object DeserializeFromStream(Type type, Stream stream) + public object? DeserializeFromStream(Type type, Stream stream) { using (var reader = XmlReader.Create(stream)) { @@ -81,7 +84,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="type">The type.</param> /// <param name="file">The file.</param> /// <returns>System.Object.</returns> - public object DeserializeFromFile(Type type, string file) + public object? DeserializeFromFile(Type type, string file) { using (var stream = File.OpenRead(file)) { @@ -95,7 +98,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="type">The type.</param> /// <param name="buffer">The buffer.</param> /// <returns>System.Object.</returns> - public object DeserializeFromBytes(Type type, byte[] buffer) + public object? DeserializeFromBytes(Type type, byte[] buffer) { using (var stream = new MemoryStream(buffer, 0, buffer.Length, false, true)) { diff --git a/Emby.Server.Implementations/ServerApplicationPaths.cs b/Emby.Server.Implementations/ServerApplicationPaths.cs index dfdd4200e..6cf9a8f71 100644 --- a/Emby.Server.Implementations/ServerApplicationPaths.cs +++ b/Emby.Server.Implementations/ServerApplicationPaths.cs @@ -25,6 +25,10 @@ namespace Emby.Server.Implementations cacheDirectoryPath, webDirectoryPath) { + // ProgramDataPath cannot change when the server is running, so cache these to avoid allocations. + RootFolderPath = Path.Join(ProgramDataPath, "root"); + DefaultUserViewsPath = Path.Combine(RootFolderPath, "default"); + DefaultInternalMetadataPath = Path.Combine(ProgramDataPath, "metadata"); InternalMetadataPath = DefaultInternalMetadataPath; } @@ -32,13 +36,13 @@ namespace Emby.Server.Implementations /// Gets the path to the base root media directory. /// </summary> /// <value>The root folder path.</value> - public string RootFolderPath => Path.Combine(ProgramDataPath, "root"); + public string RootFolderPath { get; } /// <summary> /// Gets the path to the default user view directory. Used if no specific user view is defined. /// </summary> /// <value>The default user views path.</value> - public string DefaultUserViewsPath => Path.Combine(RootFolderPath, "default"); + public string DefaultUserViewsPath { get; } /// <summary> /// Gets the path to the People directory. @@ -98,12 +102,12 @@ namespace Emby.Server.Implementations public string UserConfigurationDirectoryPath => Path.Combine(ConfigurationDirectoryPath, "users"); /// <inheritdoc/> - public string DefaultInternalMetadataPath => Path.Combine(ProgramDataPath, "metadata"); + public string DefaultInternalMetadataPath { get; } /// <inheritdoc /> public string InternalMetadataPath { get; set; } /// <inheritdoc /> - public string VirtualInternalMetadataPath { get; } = "%MetadataPath%"; + public string VirtualInternalMetadataPath => "%MetadataPath%"; } } diff --git a/Emby.Server.Implementations/Services/HttpResult.cs b/Emby.Server.Implementations/Services/HttpResult.cs deleted file mode 100644 index 8ba86f756..000000000 --- a/Emby.Server.Implementations/Services/HttpResult.cs +++ /dev/null @@ -1,64 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.Services -{ - public class HttpResult - : IHttpResult, IAsyncStreamWriter - { - public HttpResult(object response, string contentType, HttpStatusCode statusCode) - { - this.Headers = new Dictionary<string, string>(); - - this.Response = response; - this.ContentType = contentType; - this.StatusCode = statusCode; - } - - public object Response { get; set; } - - public string ContentType { get; set; } - - public IDictionary<string, string> Headers { get; private set; } - - public int Status { get; set; } - - public HttpStatusCode StatusCode - { - get => (HttpStatusCode)Status; - set => Status = (int)value; - } - - public IRequest RequestContext { get; set; } - - public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken) - { - var response = RequestContext?.Response; - - if (this.Response is byte[] bytesResponse) - { - var contentLength = bytesResponse.Length; - - if (response != null) - { - response.ContentLength = contentLength; - } - - if (contentLength > 0) - { - await responseStream.WriteAsync(bytesResponse, 0, contentLength, cancellationToken).ConfigureAwait(false); - } - - return; - } - - await ResponseHelper.WriteObject(this.RequestContext, this.Response, response).ConfigureAwait(false); - } - } -} diff --git a/Emby.Server.Implementations/Services/RequestHelper.cs b/Emby.Server.Implementations/Services/RequestHelper.cs deleted file mode 100644 index 1f9c7fc22..000000000 --- a/Emby.Server.Implementations/Services/RequestHelper.cs +++ /dev/null @@ -1,51 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Threading.Tasks; -using Emby.Server.Implementations.HttpServer; - -namespace Emby.Server.Implementations.Services -{ - public class RequestHelper - { - public static Func<Type, Stream, Task<object>> GetRequestReader(HttpListenerHost host, string contentType) - { - switch (GetContentTypeWithoutEncoding(contentType)) - { - case "application/xml": - case "text/xml": - case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml - return host.DeserializeXml; - - case "application/json": - case "text/json": - return host.DeserializeJson; - } - - return null; - } - - public static Action<object, Stream> GetResponseWriter(HttpListenerHost host, string contentType) - { - switch (GetContentTypeWithoutEncoding(contentType)) - { - case "application/xml": - case "text/xml": - case "text/xml; charset=utf-8": //"text/xml; charset=utf-8" also matches xml - return host.SerializeToXml; - - case "application/json": - case "text/json": - return host.SerializeToJson; - } - - return null; - } - - private static string GetContentTypeWithoutEncoding(string contentType) - { - return contentType?.Split(';')[0].ToLowerInvariant().Trim(); - } - } -} diff --git a/Emby.Server.Implementations/Services/ResponseHelper.cs b/Emby.Server.Implementations/Services/ResponseHelper.cs deleted file mode 100644 index a329b531d..000000000 --- a/Emby.Server.Implementations/Services/ResponseHelper.cs +++ /dev/null @@ -1,141 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.IO; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.HttpServer; -using MediaBrowser.Model.Services; -using Microsoft.AspNetCore.Http; - -namespace Emby.Server.Implementations.Services -{ - public static class ResponseHelper - { - public static Task WriteToResponse(HttpResponse response, IRequest request, object result, CancellationToken cancellationToken) - { - if (result == null) - { - if (response.StatusCode == (int)HttpStatusCode.OK) - { - response.StatusCode = (int)HttpStatusCode.NoContent; - } - - response.ContentLength = 0; - return Task.CompletedTask; - } - - var httpResult = result as IHttpResult; - if (httpResult != null) - { - httpResult.RequestContext = request; - request.ResponseContentType = httpResult.ContentType ?? request.ResponseContentType; - } - - var defaultContentType = request.ResponseContentType; - - if (httpResult != null) - { - if (httpResult.RequestContext == null) - { - httpResult.RequestContext = request; - } - - response.StatusCode = httpResult.Status; - } - - if (result is IHasHeaders responseOptions) - { - foreach (var responseHeaders in responseOptions.Headers) - { - if (string.Equals(responseHeaders.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) - { - response.ContentLength = long.Parse(responseHeaders.Value, CultureInfo.InvariantCulture); - continue; - } - - response.Headers.Add(responseHeaders.Key, responseHeaders.Value); - } - } - - // ContentType='text/html' is the default for a HttpResponse - // Do not override if another has been set - if (response.ContentType == null || response.ContentType == "text/html") - { - response.ContentType = defaultContentType; - } - - if (response.ContentType == "application/json") - { - response.ContentType += "; charset=utf-8"; - } - - switch (result) - { - case IAsyncStreamWriter asyncStreamWriter: - return asyncStreamWriter.WriteToAsync(response.Body, cancellationToken); - case IStreamWriter streamWriter: - streamWriter.WriteTo(response.Body); - return Task.CompletedTask; - case FileWriter fileWriter: - return fileWriter.WriteToAsync(response, cancellationToken); - case Stream stream: - return CopyStream(stream, response.Body); - case byte[] bytes: - response.ContentType = "application/octet-stream"; - response.ContentLength = bytes.Length; - - if (bytes.Length > 0) - { - return response.Body.WriteAsync(bytes, 0, bytes.Length, cancellationToken); - } - - return Task.CompletedTask; - case string responseText: - var responseTextAsBytes = Encoding.UTF8.GetBytes(responseText); - response.ContentLength = responseTextAsBytes.Length; - - if (responseTextAsBytes.Length > 0) - { - return response.Body.WriteAsync(responseTextAsBytes, 0, responseTextAsBytes.Length, cancellationToken); - } - - return Task.CompletedTask; - } - - return WriteObject(request, result, response); - } - - private static async Task CopyStream(Stream src, Stream dest) - { - using (src) - { - await src.CopyToAsync(dest).ConfigureAwait(false); - } - } - - public static async Task WriteObject(IRequest request, object result, HttpResponse response) - { - var contentType = request.ResponseContentType; - var serializer = RequestHelper.GetResponseWriter(HttpListenerHost.Instance, contentType); - - using (var ms = new MemoryStream()) - { - serializer(result, ms); - - ms.Position = 0; - - var contentLength = ms.Length; - response.ContentLength = contentLength; - - if (contentLength > 0) - { - await ms.CopyToAsync(response.Body).ConfigureAwait(false); - } - } - } - } -} diff --git a/Emby.Server.Implementations/Services/ServiceController.cs b/Emby.Server.Implementations/Services/ServiceController.cs deleted file mode 100644 index 47e7261e8..000000000 --- a/Emby.Server.Implementations/Services/ServiceController.cs +++ /dev/null @@ -1,202 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Threading.Tasks; -using Emby.Server.Implementations.HttpServer; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Services -{ - public delegate object ActionInvokerFn(object intance, object request); - - public delegate void VoidActionInvokerFn(object intance, object request); - - public class ServiceController - { - private readonly ILogger<ServiceController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="ServiceController"/> class. - /// </summary> - /// <param name="logger">The <see cref="ServiceController"/> logger.</param> - public ServiceController(ILogger<ServiceController> logger) - { - _logger = logger; - } - - public void Init(HttpListenerHost appHost, IEnumerable<Type> serviceTypes) - { - foreach (var serviceType in serviceTypes) - { - RegisterService(appHost, serviceType); - } - } - - public void RegisterService(HttpListenerHost appHost, Type serviceType) - { - // Make sure the provided type implements IService - if (!typeof(IService).IsAssignableFrom(serviceType)) - { - _logger.LogWarning("Tried to register a service that does not implement IService: {ServiceType}", serviceType); - return; - } - - var processedReqs = new HashSet<Type>(); - - var actions = ServiceExecGeneral.Reset(serviceType); - - foreach (var mi in serviceType.GetActions()) - { - var requestType = mi.GetParameters()[0].ParameterType; - if (processedReqs.Contains(requestType)) - { - continue; - } - - processedReqs.Add(requestType); - - ServiceExecGeneral.CreateServiceRunnersFor(requestType, actions); - - // var returnMarker = GetTypeWithGenericTypeDefinitionOf(requestType, typeof(IReturn<>)); - // var responseType = returnMarker != null ? - // GetGenericArguments(returnMarker)[0] - // : mi.ReturnType != typeof(object) && mi.ReturnType != typeof(void) ? - // mi.ReturnType - // : Type.GetType(requestType.FullName + "Response"); - - RegisterRestPaths(appHost, requestType, serviceType); - - appHost.AddServiceInfo(serviceType, requestType); - } - } - - public readonly RestPath.RestPathMap RestPathMap = new RestPath.RestPathMap(); - - public void RegisterRestPaths(HttpListenerHost appHost, Type requestType, Type serviceType) - { - var attrs = appHost.GetRouteAttributes(requestType); - foreach (var attr in attrs) - { - var restPath = new RestPath(appHost.CreateInstance, appHost.GetParseFn, requestType, serviceType, attr.Path, attr.Verbs, attr.IsHidden, attr.Summary, attr.Description); - - RegisterRestPath(restPath); - } - } - - private static readonly char[] InvalidRouteChars = new[] { '?', '&' }; - - public void RegisterRestPath(RestPath restPath) - { - if (restPath.Path[0] != '/') - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "Route '{0}' on '{1}' must start with a '/'", - restPath.Path, - restPath.RequestType.GetMethodName())); - } - - if (restPath.Path.IndexOfAny(InvalidRouteChars) != -1) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "Route '{0}' on '{1}' contains invalid chars. ", - restPath.Path, - restPath.RequestType.GetMethodName())); - } - - if (RestPathMap.TryGetValue(restPath.FirstMatchHashKey, out List<RestPath> pathsAtFirstMatch)) - { - pathsAtFirstMatch.Add(restPath); - } - else - { - RestPathMap[restPath.FirstMatchHashKey] = new List<RestPath>() { restPath }; - } - } - - public RestPath GetRestPathForRequest(string httpMethod, string pathInfo) - { - var matchUsingPathParts = RestPath.GetPathPartsForMatching(pathInfo); - - List<RestPath> firstMatches; - - var yieldedHashMatches = RestPath.GetFirstMatchHashKeys(matchUsingPathParts); - foreach (var potentialHashMatch in yieldedHashMatches) - { - if (!this.RestPathMap.TryGetValue(potentialHashMatch, out firstMatches)) - { - continue; - } - - var bestScore = -1; - RestPath bestMatch = null; - foreach (var restPath in firstMatches) - { - var score = restPath.MatchScore(httpMethod, matchUsingPathParts); - if (score > bestScore) - { - bestScore = score; - bestMatch = restPath; - } - } - - if (bestScore > 0 && bestMatch != null) - { - return bestMatch; - } - } - - var yieldedWildcardMatches = RestPath.GetFirstMatchWildCardHashKeys(matchUsingPathParts); - foreach (var potentialHashMatch in yieldedWildcardMatches) - { - if (!this.RestPathMap.TryGetValue(potentialHashMatch, out firstMatches)) - { - continue; - } - - var bestScore = -1; - RestPath bestMatch = null; - foreach (var restPath in firstMatches) - { - var score = restPath.MatchScore(httpMethod, matchUsingPathParts); - if (score > bestScore) - { - bestScore = score; - bestMatch = restPath; - } - } - - if (bestScore > 0 && bestMatch != null) - { - return bestMatch; - } - } - - return null; - } - - public Task<object> Execute(HttpListenerHost httpHost, object requestDto, IRequest req) - { - var requestType = requestDto.GetType(); - req.OperationName = requestType.Name; - - var serviceType = httpHost.GetServiceTypeByRequest(requestType); - - var service = httpHost.CreateInstance(serviceType); - - if (service is IRequiresRequest serviceRequiresContext) - { - serviceRequiresContext.Request = req; - } - - // Executes the service and returns the result - return ServiceExecGeneral.Execute(serviceType, req, service, requestDto, requestType.GetMethodName()); - } - } -} diff --git a/Emby.Server.Implementations/Services/ServiceExec.cs b/Emby.Server.Implementations/Services/ServiceExec.cs deleted file mode 100644 index 7b970627e..000000000 --- a/Emby.Server.Implementations/Services/ServiceExec.cs +++ /dev/null @@ -1,230 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Threading.Tasks; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.Services -{ - public static class ServiceExecExtensions - { - public static string[] AllVerbs = new[] { - "OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", "CONNECT", // RFC 2616 - "PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", // RFC 2518 - "VERSION-CONTROL", "REPORT", "CHECKOUT", "CHECKIN", "UNCHECKOUT", - "MKWORKSPACE", "UPDATE", "LABEL", "MERGE", "BASELINE-CONTROL", "MKACTIVITY", // RFC 3253 - "ORDERPATCH", // RFC 3648 - "ACL", // RFC 3744 - "PATCH", // https://datatracker.ietf.org/doc/draft-dusseault-http-patch/ - "SEARCH", // https://datatracker.ietf.org/doc/draft-reschke-webdav-search/ - "BCOPY", "BDELETE", "BMOVE", "BPROPFIND", "BPROPPATCH", "NOTIFY", - "POLL", "SUBSCRIBE", "UNSUBSCRIBE" - }; - - public static List<MethodInfo> GetActions(this Type serviceType) - { - var list = new List<MethodInfo>(); - - foreach (var mi in serviceType.GetRuntimeMethods()) - { - if (!mi.IsPublic) - { - continue; - } - - if (mi.IsStatic) - { - continue; - } - - if (mi.GetParameters().Length != 1) - { - continue; - } - - var actionName = mi.Name; - if (!AllVerbs.Contains(actionName, StringComparer.OrdinalIgnoreCase)) - { - continue; - } - - list.Add(mi); - } - - return list; - } - } - - internal static class ServiceExecGeneral - { - private static Dictionary<string, ServiceMethod> execMap = new Dictionary<string, ServiceMethod>(); - - public static void CreateServiceRunnersFor(Type requestType, List<ServiceMethod> actions) - { - foreach (var actionCtx in actions) - { - if (execMap.ContainsKey(actionCtx.Id)) - { - continue; - } - - execMap[actionCtx.Id] = actionCtx; - } - } - - public static Task<object> Execute(Type serviceType, IRequest request, object instance, object requestDto, string requestName) - { - var actionName = request.Verb ?? "POST"; - - if (execMap.TryGetValue(ServiceMethod.Key(serviceType, actionName, requestName), out ServiceMethod actionContext)) - { - if (actionContext.RequestFilters != null) - { - foreach (var requestFilter in actionContext.RequestFilters) - { - requestFilter.RequestFilter(request, request.Response, requestDto); - if (request.Response.HasStarted) - { - Task.FromResult<object>(null); - } - } - } - - var response = actionContext.ServiceAction(instance, requestDto); - - if (response is Task taskResponse) - { - return GetTaskResult(taskResponse); - } - - return Task.FromResult(response); - } - - var expectedMethodName = actionName.Substring(0, 1) + actionName.Substring(1).ToLowerInvariant(); - throw new NotImplementedException( - string.Format( - CultureInfo.InvariantCulture, - "Could not find method named {1}({0}) or Any({0}) on Service {2}", - requestDto.GetType().GetMethodName(), - expectedMethodName, - serviceType.GetMethodName())); - } - - private static async Task<object> GetTaskResult(Task task) - { - try - { - if (task is Task<object> taskObject) - { - return await taskObject.ConfigureAwait(false); - } - - await task.ConfigureAwait(false); - - var type = task.GetType().GetTypeInfo(); - if (!type.IsGenericType) - { - return null; - } - - var resultProperty = type.GetDeclaredProperty("Result"); - if (resultProperty == null) - { - return null; - } - - var result = resultProperty.GetValue(task); - - // hack alert - if (result.GetType().Name.IndexOf("voidtaskresult", StringComparison.OrdinalIgnoreCase) != -1) - { - return null; - } - - return result; - } - catch (TypeAccessException) - { - return null; // return null for void Task's - } - } - - public static List<ServiceMethod> Reset(Type serviceType) - { - var actions = new List<ServiceMethod>(); - - foreach (var mi in serviceType.GetActions()) - { - var actionName = mi.Name; - var args = mi.GetParameters(); - - var requestType = args[0].ParameterType; - var actionCtx = new ServiceMethod - { - Id = ServiceMethod.Key(serviceType, actionName, requestType.GetMethodName()) - }; - - actionCtx.ServiceAction = CreateExecFn(serviceType, requestType, mi); - - var reqFilters = new List<IHasRequestFilter>(); - - foreach (var attr in mi.GetCustomAttributes(true)) - { - if (attr is IHasRequestFilter hasReqFilter) - { - reqFilters.Add(hasReqFilter); - } - } - - if (reqFilters.Count > 0) - { - actionCtx.RequestFilters = reqFilters.OrderBy(i => i.Priority).ToArray(); - } - - actions.Add(actionCtx); - } - - return actions; - } - - private static ActionInvokerFn CreateExecFn(Type serviceType, Type requestType, MethodInfo mi) - { - var serviceParam = Expression.Parameter(typeof(object), "serviceObj"); - var serviceStrong = Expression.Convert(serviceParam, serviceType); - - var requestDtoParam = Expression.Parameter(typeof(object), "requestDto"); - var requestDtoStrong = Expression.Convert(requestDtoParam, requestType); - - Expression callExecute = Expression.Call( - serviceStrong, mi, requestDtoStrong); - - if (mi.ReturnType != typeof(void)) - { - var executeFunc = Expression.Lambda<ActionInvokerFn>( - callExecute, - serviceParam, - requestDtoParam).Compile(); - - return executeFunc; - } - else - { - var executeFunc = Expression.Lambda<VoidActionInvokerFn>( - callExecute, - serviceParam, - requestDtoParam).Compile(); - - return (service, request) => - { - executeFunc(service, request); - return null; - }; - } - } - } -} diff --git a/Emby.Server.Implementations/Services/ServiceHandler.cs b/Emby.Server.Implementations/Services/ServiceHandler.cs deleted file mode 100644 index b4166f771..000000000 --- a/Emby.Server.Implementations/Services/ServiceHandler.cs +++ /dev/null @@ -1,212 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Net.Mime; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.HttpServer; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Model.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Services -{ - public class ServiceHandler - { - private RestPath _restPath; - - private string _responseContentType; - - internal ServiceHandler(RestPath restPath, string responseContentType) - { - _restPath = restPath; - _responseContentType = responseContentType; - } - - protected static Task<object> CreateContentTypeRequest(HttpListenerHost host, IRequest httpReq, Type requestType, string contentType) - { - if (!string.IsNullOrEmpty(contentType) && httpReq.ContentLength > 0) - { - var deserializer = RequestHelper.GetRequestReader(host, contentType); - if (deserializer != null) - { - return deserializer.Invoke(requestType, httpReq.InputStream); - } - } - - return Task.FromResult(host.CreateInstance(requestType)); - } - - public static string GetSanitizedPathInfo(string pathInfo, out string contentType) - { - contentType = null; - var pos = pathInfo.LastIndexOf('.'); - if (pos != -1) - { - var format = pathInfo.AsSpan().Slice(pos + 1); - contentType = GetFormatContentType(format); - if (contentType != null) - { - pathInfo = pathInfo.Substring(0, pos); - } - } - - return pathInfo; - } - - private static string GetFormatContentType(ReadOnlySpan<char> format) - { - if (format.Equals("json", StringComparison.Ordinal)) - { - return MediaTypeNames.Application.Json; - } - else if (format.Equals("xml", StringComparison.Ordinal)) - { - return MediaTypeNames.Application.Xml; - } - - return null; - } - - public async Task ProcessRequestAsync(HttpListenerHost httpHost, IRequest httpReq, HttpResponse httpRes, CancellationToken cancellationToken) - { - httpReq.Items["__route"] = _restPath; - - if (_responseContentType != null) - { - httpReq.ResponseContentType = _responseContentType; - } - - var request = await CreateRequest(httpHost, httpReq, _restPath).ConfigureAwait(false); - - httpHost.ApplyRequestFilters(httpReq, httpRes, request); - - httpRes.HttpContext.SetServiceStackRequest(httpReq); - var response = await httpHost.ServiceController.Execute(httpHost, request, httpReq).ConfigureAwait(false); - - // Apply response filters - foreach (var responseFilter in httpHost.ResponseFilters) - { - responseFilter(httpReq, httpRes, response); - } - - await ResponseHelper.WriteToResponse(httpRes, httpReq, response, cancellationToken).ConfigureAwait(false); - } - - public static async Task<object> CreateRequest(HttpListenerHost host, IRequest httpReq, RestPath restPath) - { - var requestType = restPath.RequestType; - - if (RequireqRequestStream(requestType)) - { - // Used by IRequiresRequestStream - var requestParams = GetRequestParams(httpReq.Response.HttpContext.Request); - var request = ServiceHandler.CreateRequest(httpReq, restPath, requestParams, host.CreateInstance(requestType)); - - var rawReq = (IRequiresRequestStream)request; - rawReq.RequestStream = httpReq.InputStream; - return rawReq; - } - else - { - var requestParams = GetFlattenedRequestParams(httpReq.Response.HttpContext.Request); - - var requestDto = await CreateContentTypeRequest(host, httpReq, restPath.RequestType, httpReq.ContentType).ConfigureAwait(false); - - return CreateRequest(httpReq, restPath, requestParams, requestDto); - } - } - - public static bool RequireqRequestStream(Type requestType) - { - var requiresRequestStreamTypeInfo = typeof(IRequiresRequestStream).GetTypeInfo(); - - return requiresRequestStreamTypeInfo.IsAssignableFrom(requestType.GetTypeInfo()); - } - - public static object CreateRequest(IRequest httpReq, RestPath restPath, Dictionary<string, string> requestParams, object requestDto) - { - var pathInfo = !restPath.IsWildCardPath - ? GetSanitizedPathInfo(httpReq.PathInfo, out _) - : httpReq.PathInfo; - - return restPath.CreateRequest(pathInfo, requestParams, requestDto); - } - - /// <summary> - /// Duplicate Params are given a unique key by appending a #1 suffix - /// </summary> - private static Dictionary<string, string> GetRequestParams(HttpRequest request) - { - var map = new Dictionary<string, string>(); - - foreach (var pair in request.Query) - { - var values = pair.Value; - if (values.Count == 1) - { - map[pair.Key] = values[0]; - } - else - { - for (var i = 0; i < values.Count; i++) - { - map[pair.Key + (i == 0 ? string.Empty : "#" + i)] = values[i]; - } - } - } - - if ((IsMethod(request.Method, "POST") || IsMethod(request.Method, "PUT")) - && request.HasFormContentType) - { - foreach (var pair in request.Form) - { - var values = pair.Value; - if (values.Count == 1) - { - map[pair.Key] = values[0]; - } - else - { - for (var i = 0; i < values.Count; i++) - { - map[pair.Key + (i == 0 ? string.Empty : "#" + i)] = values[i]; - } - } - } - } - - return map; - } - - private static bool IsMethod(string method, string expected) - => string.Equals(method, expected, StringComparison.OrdinalIgnoreCase); - - /// <summary> - /// Duplicate params have their values joined together in a comma-delimited string. - /// </summary> - private static Dictionary<string, string> GetFlattenedRequestParams(HttpRequest request) - { - var map = new Dictionary<string, string>(); - - foreach (var pair in request.Query) - { - map[pair.Key] = pair.Value; - } - - if ((IsMethod(request.Method, "POST") || IsMethod(request.Method, "PUT")) - && request.HasFormContentType) - { - foreach (var pair in request.Form) - { - map[pair.Key] = pair.Value; - } - } - - return map; - } - } -} diff --git a/Emby.Server.Implementations/Services/ServiceMethod.cs b/Emby.Server.Implementations/Services/ServiceMethod.cs deleted file mode 100644 index 5116cc04f..000000000 --- a/Emby.Server.Implementations/Services/ServiceMethod.cs +++ /dev/null @@ -1,20 +0,0 @@ -#pragma warning disable CS1591 - -using System; - -namespace Emby.Server.Implementations.Services -{ - public class ServiceMethod - { - public string Id { get; set; } - - public ActionInvokerFn ServiceAction { get; set; } - - public MediaBrowser.Model.Services.IHasRequestFilter[] RequestFilters { get; set; } - - public static string Key(Type serviceType, string method, string requestDtoName) - { - return serviceType.FullName + " " + method.ToUpperInvariant() + " " + requestDtoName; - } - } -} diff --git a/Emby.Server.Implementations/Services/ServicePath.cs b/Emby.Server.Implementations/Services/ServicePath.cs deleted file mode 100644 index 0d4728b43..000000000 --- a/Emby.Server.Implementations/Services/ServicePath.cs +++ /dev/null @@ -1,550 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.Services -{ - public class RestPath - { - private const string WildCard = "*"; - private const char WildCardChar = '*'; - private const string PathSeperator = "/"; - private const char PathSeperatorChar = '/'; - private const char ComponentSeperator = '.'; - private const string VariablePrefix = "{"; - - private readonly bool[] componentsWithSeparators; - - private readonly string restPath; - public bool IsWildCardPath { get; private set; } - - private readonly string[] literalsToMatch; - - private readonly string[] variablesNames; - - private readonly bool[] isWildcard; - private readonly int wildcardCount = 0; - - internal static string[] IgnoreAttributesNamed = new[] - { - nameof(JsonIgnoreAttribute) - }; - - private static Type _excludeType = typeof(Stream); - - public int VariableArgsCount { get; set; } - - /// <summary> - /// The number of segments separated by '/' determinable by path.Split('/').Length - /// e.g. /path/to/here.ext == 3 - /// </summary> - public int PathComponentsCount { get; set; } - - /// <summary> - /// Gets or sets the total number of segments after subparts have been exploded ('.') - /// e.g. /path/to/here.ext == 4. - /// </summary> - public int TotalComponentsCount { get; set; } - - public string[] Verbs { get; private set; } - - public Type RequestType { get; private set; } - - public Type ServiceType { get; private set; } - - public string Path => this.restPath; - - public string Summary { get; private set; } - - public string Description { get; private set; } - - public bool IsHidden { get; private set; } - - public static string[] GetPathPartsForMatching(string pathInfo) - { - return pathInfo.ToLowerInvariant().Split(new[] { PathSeperatorChar }, StringSplitOptions.RemoveEmptyEntries); - } - - public static List<string> GetFirstMatchHashKeys(string[] pathPartsForMatching) - { - var hashPrefix = pathPartsForMatching.Length + PathSeperator; - return GetPotentialMatchesWithPrefix(hashPrefix, pathPartsForMatching); - } - - public static List<string> GetFirstMatchWildCardHashKeys(string[] pathPartsForMatching) - { - const string HashPrefix = WildCard + PathSeperator; - return GetPotentialMatchesWithPrefix(HashPrefix, pathPartsForMatching); - } - - private static List<string> GetPotentialMatchesWithPrefix(string hashPrefix, string[] pathPartsForMatching) - { - var list = new List<string>(); - - foreach (var part in pathPartsForMatching) - { - list.Add(hashPrefix + part); - - if (part.IndexOf(ComponentSeperator, StringComparison.Ordinal) == -1) - { - continue; - } - - var subParts = part.Split(ComponentSeperator); - foreach (var subPart in subParts) - { - list.Add(hashPrefix + subPart); - } - } - - return list; - } - - public RestPath(Func<Type, object> createInstanceFn, Func<Type, Func<string, object>> getParseFn, Type requestType, Type serviceType, string path, string verbs, bool isHidden = false, string summary = null, string description = null) - { - this.RequestType = requestType; - this.ServiceType = serviceType; - this.Summary = summary; - this.IsHidden = isHidden; - this.Description = description; - this.restPath = path; - - this.Verbs = string.IsNullOrWhiteSpace(verbs) ? ServiceExecExtensions.AllVerbs : verbs.ToUpperInvariant().Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries); - - var componentsList = new List<string>(); - - // We only split on '.' if the restPath has them. Allows for /{action}.{type} - var hasSeparators = new List<bool>(); - foreach (var component in this.restPath.Split(PathSeperatorChar)) - { - if (string.IsNullOrEmpty(component)) - { - continue; - } - - if (component.IndexOf(VariablePrefix, StringComparison.OrdinalIgnoreCase) != -1 - && component.IndexOf(ComponentSeperator, StringComparison.Ordinal) != -1) - { - hasSeparators.Add(true); - componentsList.AddRange(component.Split(ComponentSeperator)); - } - else - { - hasSeparators.Add(false); - componentsList.Add(component); - } - } - - var components = componentsList.ToArray(); - this.TotalComponentsCount = components.Length; - - this.literalsToMatch = new string[this.TotalComponentsCount]; - this.variablesNames = new string[this.TotalComponentsCount]; - this.isWildcard = new bool[this.TotalComponentsCount]; - this.componentsWithSeparators = hasSeparators.ToArray(); - this.PathComponentsCount = this.componentsWithSeparators.Length; - string firstLiteralMatch = null; - - for (var i = 0; i < components.Length; i++) - { - var component = components[i]; - - if (component.StartsWith(VariablePrefix, StringComparison.Ordinal)) - { - var variableName = component.Substring(1, component.Length - 2); - if (variableName[variableName.Length - 1] == WildCardChar) - { - this.isWildcard[i] = true; - variableName = variableName.Substring(0, variableName.Length - 1); - } - - this.variablesNames[i] = variableName; - this.VariableArgsCount++; - } - else - { - this.literalsToMatch[i] = component.ToLowerInvariant(); - - if (firstLiteralMatch == null) - { - firstLiteralMatch = this.literalsToMatch[i]; - } - } - } - - for (var i = 0; i < components.Length - 1; i++) - { - if (!this.isWildcard[i]) - { - continue; - } - - if (this.literalsToMatch[i + 1] == null) - { - throw new ArgumentException( - "A wildcard path component must be at the end of the path or followed by a literal path component."); - } - } - - this.wildcardCount = this.isWildcard.Length; - this.IsWildCardPath = this.wildcardCount > 0; - - this.FirstMatchHashKey = !this.IsWildCardPath - ? this.PathComponentsCount + PathSeperator + firstLiteralMatch - : WildCardChar + PathSeperator + firstLiteralMatch; - - this.typeDeserializer = new StringMapTypeDeserializer(createInstanceFn, getParseFn, this.RequestType); - - _propertyNamesMap = new HashSet<string>( - GetSerializableProperties(RequestType).Select(x => x.Name), - StringComparer.OrdinalIgnoreCase); - } - - internal static IEnumerable<PropertyInfo> GetSerializableProperties(Type type) - { - foreach (var prop in GetPublicProperties(type)) - { - if (prop.GetMethod == null - || _excludeType == prop.PropertyType) - { - continue; - } - - var ignored = false; - foreach (var attr in prop.GetCustomAttributes(true)) - { - if (IgnoreAttributesNamed.Contains(attr.GetType().Name)) - { - ignored = true; - break; - } - } - - if (!ignored) - { - yield return prop; - } - } - } - - private static IEnumerable<PropertyInfo> GetPublicProperties(Type type) - { - if (type.IsInterface) - { - var propertyInfos = new List<PropertyInfo>(); - var considered = new List<Type>() - { - type - }; - var queue = new Queue<Type>(); - queue.Enqueue(type); - - while (queue.Count > 0) - { - var subType = queue.Dequeue(); - foreach (var subInterface in subType.GetTypeInfo().ImplementedInterfaces) - { - if (considered.Contains(subInterface)) - { - continue; - } - - considered.Add(subInterface); - queue.Enqueue(subInterface); - } - - var newPropertyInfos = GetTypesPublicProperties(subType) - .Where(x => !propertyInfos.Contains(x)); - - propertyInfos.InsertRange(0, newPropertyInfos); - } - - return propertyInfos; - } - - return GetTypesPublicProperties(type) - .Where(x => x.GetIndexParameters().Length == 0); - } - - private static IEnumerable<PropertyInfo> GetTypesPublicProperties(Type subType) - { - foreach (var pi in subType.GetRuntimeProperties()) - { - var mi = pi.GetMethod ?? pi.SetMethod; - if (mi != null && mi.IsStatic) - { - continue; - } - - yield return pi; - } - } - - /// <summary> - /// Provide for quick lookups based on hashes that can be determined from a request url. - /// </summary> - public string FirstMatchHashKey { get; private set; } - - private readonly StringMapTypeDeserializer typeDeserializer; - - private readonly HashSet<string> _propertyNamesMap; - - public int MatchScore(string httpMethod, string[] withPathInfoParts) - { - var isMatch = IsMatch(httpMethod, withPathInfoParts, out var wildcardMatchCount); - if (!isMatch) - { - return -1; - } - - // Routes with least wildcard matches get the highest score - var score = Math.Max(100 - wildcardMatchCount, 1) * 1000 - // Routes with less variable (and more literal) matches - + Math.Max(10 - VariableArgsCount, 1) * 100; - - // Exact verb match is better than ANY - if (Verbs.Length == 1 && string.Equals(httpMethod, Verbs[0], StringComparison.OrdinalIgnoreCase)) - { - score += 10; - } - else - { - score += 1; - } - - return score; - } - - /// <summary> - /// For performance withPathInfoParts should already be a lower case string - /// to minimize redundant matching operations. - /// </summary> - public bool IsMatch(string httpMethod, string[] withPathInfoParts, out int wildcardMatchCount) - { - wildcardMatchCount = 0; - - if (withPathInfoParts.Length != this.PathComponentsCount && !this.IsWildCardPath) - { - return false; - } - - if (!Verbs.Contains(httpMethod, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - - if (!ExplodeComponents(ref withPathInfoParts)) - { - return false; - } - - if (this.TotalComponentsCount != withPathInfoParts.Length && !this.IsWildCardPath) - { - return false; - } - - int pathIx = 0; - for (var i = 0; i < this.TotalComponentsCount; i++) - { - if (this.isWildcard[i]) - { - if (i < this.TotalComponentsCount - 1) - { - // Continue to consume up until a match with the next literal - while (pathIx < withPathInfoParts.Length - && !string.Equals(withPathInfoParts[pathIx], this.literalsToMatch[i + 1], StringComparison.InvariantCultureIgnoreCase)) - { - pathIx++; - wildcardMatchCount++; - } - - // Ensure there are still enough parts left to match the remainder - if ((withPathInfoParts.Length - pathIx) < (this.TotalComponentsCount - i - 1)) - { - return false; - } - } - else - { - // A wildcard at the end matches the remainder of path - wildcardMatchCount += withPathInfoParts.Length - pathIx; - pathIx = withPathInfoParts.Length; - } - } - else - { - var literalToMatch = this.literalsToMatch[i]; - if (literalToMatch == null) - { - // Matching an ordinary (non-wildcard) variable consumes a single part - pathIx++; - continue; - } - - if (withPathInfoParts.Length <= pathIx - || !string.Equals(withPathInfoParts[pathIx], literalToMatch, StringComparison.InvariantCultureIgnoreCase)) - { - return false; - } - - pathIx++; - } - } - - return pathIx == withPathInfoParts.Length; - } - - private bool ExplodeComponents(ref string[] withPathInfoParts) - { - var totalComponents = new List<string>(); - for (var i = 0; i < withPathInfoParts.Length; i++) - { - var component = withPathInfoParts[i]; - if (string.IsNullOrEmpty(component)) - { - continue; - } - - if (this.PathComponentsCount != this.TotalComponentsCount - && this.componentsWithSeparators[i]) - { - var subComponents = component.Split(ComponentSeperator); - if (subComponents.Length < 2) - { - return false; - } - - totalComponents.AddRange(subComponents); - } - else - { - totalComponents.Add(component); - } - } - - withPathInfoParts = totalComponents.ToArray(); - return true; - } - - public object CreateRequest(string pathInfo, Dictionary<string, string> queryStringAndFormData, object fromInstance) - { - var requestComponents = pathInfo.Split(new[] { PathSeperatorChar }, StringSplitOptions.RemoveEmptyEntries); - - ExplodeComponents(ref requestComponents); - - if (requestComponents.Length != this.TotalComponentsCount) - { - var isValidWildCardPath = this.IsWildCardPath - && requestComponents.Length >= this.TotalComponentsCount - this.wildcardCount; - - if (!isValidWildCardPath) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "Path Mismatch: Request Path '{0}' has invalid number of components compared to: '{1}'", - pathInfo, - this.restPath)); - } - } - - var requestKeyValuesMap = new Dictionary<string, string>(); - var pathIx = 0; - for (var i = 0; i < this.TotalComponentsCount; i++) - { - var variableName = this.variablesNames[i]; - if (variableName == null) - { - pathIx++; - continue; - } - - if (!this._propertyNamesMap.Contains(variableName)) - { - if (string.Equals("ignore", variableName, StringComparison.OrdinalIgnoreCase)) - { - pathIx++; - continue; - } - - throw new ArgumentException("Could not find property " - + variableName + " on " + RequestType.GetMethodName()); - } - - var value = requestComponents.Length > pathIx ? requestComponents[pathIx] : null; // wildcard has arg mismatch - if (value != null && this.isWildcard[i]) - { - if (i == this.TotalComponentsCount - 1) - { - // Wildcard at end of path definition consumes all the rest - var sb = new StringBuilder(); - sb.Append(value); - for (var j = pathIx + 1; j < requestComponents.Length; j++) - { - sb.Append(PathSeperatorChar) - .Append(requestComponents[j]); - } - - value = sb.ToString(); - } - else - { - // Wildcard in middle of path definition consumes up until it - // hits a match for the next element in the definition (which must be a literal) - // It may consume 0 or more path parts - var stopLiteral = i == this.TotalComponentsCount - 1 ? null : this.literalsToMatch[i + 1]; - if (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase)) - { - var sb = new StringBuilder(value); - pathIx++; - while (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase)) - { - sb.Append(PathSeperatorChar) - .Append(requestComponents[pathIx++]); - } - - value = sb.ToString(); - } - else - { - value = null; - } - } - } - else - { - // Variable consumes single path item - pathIx++; - } - - requestKeyValuesMap[variableName] = value; - } - - if (queryStringAndFormData != null) - { - // Query String and form data can override variable path matches - // path variables < query string < form data - foreach (var name in queryStringAndFormData) - { - requestKeyValuesMap[name.Key] = name.Value; - } - } - - return this.typeDeserializer.PopulateFromMap(fromInstance, requestKeyValuesMap); - } - - public class RestPathMap : SortedDictionary<string, List<RestPath>> - { - public RestPathMap() : base(StringComparer.OrdinalIgnoreCase) - { - } - } - } -} diff --git a/Emby.Server.Implementations/Services/StringMapTypeDeserializer.cs b/Emby.Server.Implementations/Services/StringMapTypeDeserializer.cs deleted file mode 100644 index 165bb0fc4..000000000 --- a/Emby.Server.Implementations/Services/StringMapTypeDeserializer.cs +++ /dev/null @@ -1,118 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Reflection; -using MediaBrowser.Common.Extensions; - -namespace Emby.Server.Implementations.Services -{ - /// <summary> - /// Serializer cache of delegates required to create a type from a string map (e.g. for REST urls) - /// </summary> - public class StringMapTypeDeserializer - { - internal class PropertySerializerEntry - { - public PropertySerializerEntry(Action<object, object> propertySetFn, Func<string, object> propertyParseStringFn, Type propertyType) - { - PropertySetFn = propertySetFn; - PropertyParseStringFn = propertyParseStringFn; - PropertyType = propertyType; - } - - public Action<object, object> PropertySetFn { get; private set; } - - public Func<string, object> PropertyParseStringFn { get; private set; } - - public Type PropertyType { get; private set; } - } - - private readonly Type type; - private readonly Dictionary<string, PropertySerializerEntry> propertySetterMap - = new Dictionary<string, PropertySerializerEntry>(StringComparer.OrdinalIgnoreCase); - - public Func<string, object> GetParseFn(Type propertyType) - { - if (propertyType == typeof(string)) - { - return s => s; - } - - return _GetParseFn(propertyType); - } - - private readonly Func<Type, object> _CreateInstanceFn; - private readonly Func<Type, Func<string, object>> _GetParseFn; - - public StringMapTypeDeserializer(Func<Type, object> createInstanceFn, Func<Type, Func<string, object>> getParseFn, Type type) - { - _CreateInstanceFn = createInstanceFn; - _GetParseFn = getParseFn; - this.type = type; - - foreach (var propertyInfo in RestPath.GetSerializableProperties(type)) - { - var propertySetFn = TypeAccessor.GetSetPropertyMethod(propertyInfo); - var propertyType = propertyInfo.PropertyType; - var propertyParseStringFn = GetParseFn(propertyType); - var propertySerializer = new PropertySerializerEntry(propertySetFn, propertyParseStringFn, propertyType); - - propertySetterMap[propertyInfo.Name] = propertySerializer; - } - } - - public object PopulateFromMap(object instance, IDictionary<string, string> keyValuePairs) - { - PropertySerializerEntry propertySerializerEntry = null; - - if (instance == null) - { - instance = _CreateInstanceFn(type); - } - - foreach (var pair in keyValuePairs) - { - string propertyName = pair.Key; - string propertyTextValue = pair.Value; - - if (propertyTextValue == null - || !propertySetterMap.TryGetValue(propertyName, out propertySerializerEntry) - || propertySerializerEntry.PropertySetFn == null) - { - continue; - } - - if (propertySerializerEntry.PropertyType == typeof(bool)) - { - // InputExtensions.cs#530 MVC Checkbox helper emits extra hidden input field, generating 2 values, first is the real value - propertyTextValue = StringExtensions.LeftPart(propertyTextValue, ',').ToString(); - } - - var value = propertySerializerEntry.PropertyParseStringFn(propertyTextValue); - if (value == null) - { - continue; - } - - propertySerializerEntry.PropertySetFn(instance, value); - } - - return instance; - } - } - - internal static class TypeAccessor - { - public static Action<object, object> GetSetPropertyMethod(PropertyInfo propertyInfo) - { - if (!propertyInfo.CanWrite || propertyInfo.GetIndexParameters().Length > 0) - { - return null; - } - - var setMethodInfo = propertyInfo.SetMethod; - return (instance, value) => setMethodInfo.Invoke(instance, new[] { value }); - } - } -} diff --git a/Emby.Server.Implementations/Services/UrlExtensions.cs b/Emby.Server.Implementations/Services/UrlExtensions.cs deleted file mode 100644 index 92e36b60e..000000000 --- a/Emby.Server.Implementations/Services/UrlExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Common.Extensions; - -namespace Emby.Server.Implementations.Services -{ - /// <summary> - /// Donated by Ivan Korneliuk from his post: - /// http://korneliuk.blogspot.com/2012/08/servicestack-reusing-dtos.html - /// - /// Modified to only allow using routes matching the supplied HTTP Verb. - /// </summary> - public static class UrlExtensions - { - public static string GetMethodName(this Type type) - { - var typeName = type.FullName != null // can be null, e.g. generic types - ? StringExtensions.LeftPart(type.FullName, "[[", StringComparison.Ordinal).ToString() // Generic Fullname - .Replace(type.Namespace + ".", string.Empty, StringComparison.Ordinal) // Trim Namespaces - .Replace("+", ".", StringComparison.Ordinal) // Convert nested into normal type - : type.Name; - - return type.IsGenericParameter ? "'" + typeName : typeName; - } - } -} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index dad414142..c4b19f417 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; @@ -10,6 +12,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using Jellyfin.Extensions; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -58,8 +61,7 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The active connections. /// </summary> - private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = - new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase); private Timer _idleTimer; @@ -129,6 +131,9 @@ namespace Emby.Server.Implementations.Session /// <inheritdoc /> public event EventHandler<SessionEventArgs> SessionActivity; + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> SessionControllerConnected; + /// <summary> /// Gets all connections. /// </summary> @@ -196,7 +201,7 @@ namespace Emby.Server.Implementations.Session { if (!string.IsNullOrEmpty(info.DeviceId)) { - var capabilities = GetSavedCapabilities(info.DeviceId); + var capabilities = _deviceManager.GetCapabilities(info.DeviceId); if (capabilities != null) { @@ -314,6 +319,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)) @@ -666,7 +684,7 @@ namespace Emby.Server.Implementations.Session } } - var eventArgs = new PlaybackProgressEventArgs + var eventArgs = new PlaybackStartEventArgs { Item = libraryItem, Users = users, @@ -1037,7 +1055,7 @@ namespace Emby.Server.Implementations.Session var generalCommand = new GeneralCommand { - Name = GeneralCommandType.DisplayMessage.ToString() + Name = GeneralCommandType.DisplayMessage }; generalCommand.Arguments["Header"] = command.Header; @@ -1064,10 +1082,10 @@ namespace Emby.Server.Implementations.Session AssertCanControl(session, controllingSession); } - return SendMessageToSession(session, "GeneralCommand", command, cancellationToken); + return SendMessageToSession(session, SessionMessageType.GeneralCommand, command, cancellationToken); } - private static async Task SendMessageToSession<T>(SessionInfo session, string name, T data, CancellationToken cancellationToken) + private static async Task SendMessageToSession<T>(SessionInfo session, SessionMessageType name, T data, CancellationToken cancellationToken) { var controllers = session.SessionControllers; var messageId = Guid.NewGuid(); @@ -1078,7 +1096,7 @@ namespace Emby.Server.Implementations.Session } } - private static Task SendMessageToSessions<T>(IEnumerable<SessionInfo> sessions, string name, T data, CancellationToken cancellationToken) + private static Task SendMessageToSessions<T>(IEnumerable<SessionInfo> sessions, SessionMessageType name, T data, CancellationToken cancellationToken) { IEnumerable<Task> GetTasks() { @@ -1178,23 +1196,21 @@ namespace Emby.Server.Implementations.Session } } - await SendMessageToSession(session, "Play", command, cancellationToken).ConfigureAwait(false); + await SendMessageToSession(session, SessionMessageType.Play, command, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> - public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken) + public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); - await SendMessageToSession(session, "SyncPlayCommand", command, cancellationToken).ConfigureAwait(false); + await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> - public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken) + public async Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); - await SendMessageToSession(session, "SyncPlayGroupUpdate", command, cancellationToken).ConfigureAwait(false); + await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false); } private IEnumerable<BaseItem> TranslateItemForPlayback(Guid id, User user) @@ -1268,7 +1284,7 @@ namespace Emby.Server.Implementations.Session { var generalCommand = new GeneralCommand { - Name = GeneralCommandType.DisplayContent.ToString(), + Name = GeneralCommandType.DisplayContent, Arguments = { ["ItemId"] = command.ItemId, @@ -1297,7 +1313,7 @@ namespace Emby.Server.Implementations.Session } } - return SendMessageToSession(session, "Playstate", command, cancellationToken); + return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken); } private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession) @@ -1322,7 +1338,7 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - return SendMessageToSessions(Sessions, "RestartRequired", string.Empty, cancellationToken); + return SendMessageToSessions(Sessions, SessionMessageType.RestartRequired, string.Empty, cancellationToken); } /// <summary> @@ -1334,7 +1350,7 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - return SendMessageToSessions(Sessions, "ServerShuttingDown", string.Empty, cancellationToken); + return SendMessageToSessions(Sessions, SessionMessageType.ServerShuttingDown, string.Empty, cancellationToken); } /// <summary> @@ -1348,7 +1364,7 @@ namespace Emby.Server.Implementations.Session _logger.LogDebug("Beginning SendServerRestartNotification"); - return SendMessageToSessions(Sessions, "ServerRestarting", string.Empty, cancellationToken); + return SendMessageToSessions(Sessions, SessionMessageType.ServerRestarting, string.Empty, cancellationToken); } /// <summary> @@ -1429,6 +1445,29 @@ namespace Emby.Server.Implementations.Session return AuthenticateNewSessionInternal(request, false); } + public Task<AuthenticationResult> AuthenticateQuickConnect(AuthenticationRequest request, string token) + { + var result = _authRepo.Get(new AuthenticationInfoQuery() + { + AccessToken = token, + DeviceId = _appHost.SystemId, + Limit = 1 + }); + + if (result.TotalRecordCount == 0) + { + throw new SecurityException("Unknown quick connect token"); + } + + var info = result.Items[0]; + request.UserId = info.UserId; + + // There's no need to keep the quick connect token in the database, as AuthenticateNewSessionInternal() issues a long lived token. + _authRepo.Delete(info); + + return AuthenticateNewSessionInternal(request, false); + } + private async Task<AuthenticationResult> AuthenticateNewSessionInternal(AuthenticationRequest request, bool enforcePassword) { CheckDisposed(); @@ -1439,17 +1478,14 @@ namespace Emby.Server.Implementations.Session user = _userManager.GetUserById(request.UserId); } - if (user == null) - { - user = _userManager.GetUserByName(request.Username); - } + user ??= _userManager.GetUserByName(request.Username); if (enforcePassword) { user = await _userManager.AuthenticateUser( request.Username, request.Password, - request.PasswordSha1, + null, request.RemoteEndPoint, true).ConfigureAwait(false); } @@ -1466,6 +1502,14 @@ namespace Emby.Server.Implementations.Session throw new SecurityException("User is not allowed access from this device."); } + int sessionsCount = Sessions.Count(i => i.UserId.Equals(user.Id)); + int maxActiveSessions = user.MaxActiveSessions; + _logger.LogInformation("Current/Max sessions for user {User}: {Sessions}/{Max}", user.Username, sessionsCount, maxActiveSessions); + if (maxActiveSessions >= 1 && sessionsCount >= maxActiveSessions) + { + throw new SecurityException("User is at their maximum number of sessions."); + } + var token = GetAuthorizationToken(user, request.DeviceId, request.App, request.AppVersion, request.DeviceName); var session = LogSessionActivity( @@ -1499,23 +1543,26 @@ namespace Emby.Server.Implementations.Session Limit = 1 }).Items.FirstOrDefault(); - var allExistingForDevice = _authRepo.Get( - new AuthenticationInfoQuery - { - DeviceId = deviceId - }).Items; - - foreach (var auth in allExistingForDevice) + if (!string.IsNullOrEmpty(deviceId)) { - if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) - { - try + var allExistingForDevice = _authRepo.Get( + new AuthenticationInfoQuery { - Logout(auth); - } - catch (Exception ex) + DeviceId = deviceId + }).Items; + + foreach (var auth in allExistingForDevice) + { + if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) { - _logger.LogError(ex, "Error while logging out."); + try + { + Logout(auth); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while logging out."); + } } } } @@ -1651,27 +1698,10 @@ namespace Emby.Server.Implementations.Session SessionInfo = session }); - try - { - SaveCapabilities(session.DeviceId, capabilities); - } - catch (Exception ex) - { - _logger.LogError("Error saving device capabilities", ex); - } + _deviceManager.SaveCapabilities(session.DeviceId, capabilities); } } - private ClientCapabilities GetSavedCapabilities(string deviceId) - { - return _deviceManager.GetCapabilities(deviceId); - } - - private void SaveCapabilities(string deviceId, ClientCapabilities capabilities) - { - _deviceManager.SaveCapabilities(deviceId, capabilities); - } - /// <summary> /// Converts a BaseItem to a BaseItemInfo. /// </summary> @@ -1848,7 +1878,7 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public Task SendMessageToAdminSessions<T>(string name, T data, CancellationToken cancellationToken) + public Task SendMessageToAdminSessions<T>(SessionMessageType name, T data, CancellationToken cancellationToken) { CheckDisposed(); @@ -1861,7 +1891,7 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public Task SendMessageToUserSessions<T>(List<Guid> userIds, string name, Func<T> dataFn, CancellationToken cancellationToken) + public Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, Func<T> dataFn, CancellationToken cancellationToken) { CheckDisposed(); @@ -1876,7 +1906,7 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public Task SendMessageToUserSessions<T>(List<Guid> userIds, string name, T data, CancellationToken cancellationToken) + public Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, T data, CancellationToken cancellationToken) { CheckDisposed(); @@ -1885,7 +1915,7 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public Task SendMessageToUserDeviceSessions<T>(string deviceId, string name, T data, CancellationToken cancellationToken) + public Task SendMessageToUserDeviceSessions<T>(string deviceId, SessionMessageType name, T data, CancellationToken cancellationToken) { CheckDisposed(); diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 1da7a6473..e9e3ca7f4 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -1,13 +1,15 @@ +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Net; +using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -21,50 +23,48 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The timeout in seconds after which a WebSocket is considered to be lost. /// </summary> - public const int WebSocketLostTimeout = 60; + private const int WebSocketLostTimeout = 60; /// <summary> /// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets. /// </summary> - public const float IntervalFactor = 0.2f; + private const float IntervalFactor = 0.2f; /// <summary> /// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent. /// </summary> - public const float ForceKeepAliveFactor = 0.75f; + private const float ForceKeepAliveFactor = 0.75f; /// <summary> - /// The _session manager. + /// Lock used for accesing the KeepAlive cancellation token. /// </summary> - private readonly ISessionManager _sessionManager; + private readonly object _keepAliveLock = new object(); /// <summary> - /// The _logger. + /// The WebSocket watchlist. /// </summary> - private readonly ILogger<SessionWebSocketListener> _logger; - private readonly ILoggerFactory _loggerFactory; - - private readonly IHttpServer _httpServer; + private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>(); /// <summary> - /// The KeepAlive cancellation token. + /// Lock used for accessing the WebSockets watchlist. /// </summary> - private CancellationTokenSource _keepAliveCancellationToken; + private readonly object _webSocketsLock = new object(); /// <summary> - /// Lock used for accesing the KeepAlive cancellation token. + /// The _session manager. /// </summary> - private readonly object _keepAliveLock = new object(); + private readonly ISessionManager _sessionManager; /// <summary> - /// The WebSocket watchlist. + /// The _logger. /// </summary> - private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>(); + private readonly ILogger<SessionWebSocketListener> _logger; + private readonly ILoggerFactory _loggerFactory; /// <summary> - /// Lock used for accesing the WebSockets watchlist. + /// The KeepAlive cancellation token. /// </summary> - private readonly object _webSocketsLock = new object(); + private CancellationTokenSource _keepAliveCancellationToken; /// <summary> /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class. @@ -72,32 +72,42 @@ namespace Emby.Server.Implementations.Session /// <param name="logger">The logger.</param> /// <param name="sessionManager">The session manager.</param> /// <param name="loggerFactory">The logger factory.</param> - /// <param name="httpServer">The HTTP server.</param> public SessionWebSocketListener( ILogger<SessionWebSocketListener> logger, ISessionManager sessionManager, - ILoggerFactory loggerFactory, - IHttpServer httpServer) + ILoggerFactory loggerFactory) { _logger = logger; _sessionManager = sessionManager; _loggerFactory = loggerFactory; - _httpServer = httpServer; + } - httpServer.WebSocketConnected += OnServerManagerWebSocketConnected; + /// <inheritdoc /> + public void Dispose() + { + StopKeepAlive(); } - private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e) + /// <summary> + /// Processes the message. + /// </summary> + /// <param name="message">The message.</param> + /// <returns>Task.</returns> + public Task ProcessMessageAsync(WebSocketMessageInfo message) + => Task.CompletedTask; + + /// <inheritdoc /> + public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) { - var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString()); + var session = GetSession(connection.QueryString, connection.RemoteEndPoint.ToString()); if (session != null) { - EnsureController(session, e.Argument); - await KeepAliveWebSocket(e.Argument).ConfigureAwait(false); + EnsureController(session, connection); + await KeepAliveWebSocket(connection).ConfigureAwait(false); } else { - _logger.LogWarning("Unable to determine session based on query string: {0}", e.Argument.QueryString); + _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString); } } @@ -118,21 +128,6 @@ namespace Emby.Server.Implementations.Session return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint); } - /// <inheritdoc /> - public void Dispose() - { - _httpServer.WebSocketConnected -= OnServerManagerWebSocketConnected; - StopKeepAlive(); - } - - /// <summary> - /// Processes the message. - /// </summary> - /// <param name="message">The message.</param> - /// <returns>Task.</returns> - public Task ProcessMessageAsync(WebSocketMessageInfo message) - => Task.CompletedTask; - private void EnsureController(SessionInfo session, IWebSocketConnection connection) { var controllerInfo = session.EnsureController<WebSocketController>( @@ -140,6 +135,8 @@ namespace Emby.Server.Implementations.Session var controller = (WebSocketController)controllerInfo.Item1; controller.AddWebSocket(connection); + + _sessionManager.OnSessionControllerConnected(session); } /// <summary> @@ -316,7 +313,7 @@ namespace Emby.Server.Implementations.Session return webSocket.SendAsync( new WebSocketMessage<int> { - MessageType = "ForceKeepAlive", + MessageType = SessionMessageType.ForceKeepAlive, Data = WebSocketLostTimeout }, CancellationToken.None); diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs index 94604ca1e..9fa92a53a 100644 --- a/Emby.Server.Implementations/Session/WebSocketController.cs +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -1,6 +1,4 @@ #pragma warning disable CS1591 -#pragma warning disable SA1600 -#nullable enable using System; using System.Collections.Generic; @@ -11,6 +9,7 @@ using System.Threading.Tasks; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Net; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Session @@ -54,9 +53,9 @@ namespace Emby.Server.Implementations.Session connection.Closed += OnConnectionClosed; } - private void OnConnectionClosed(object sender, EventArgs e) + private void OnConnectionClosed(object? sender, EventArgs e) { - var connection = (IWebSocketConnection)sender; + var connection = sender as IWebSocketConnection ?? throw new ArgumentException($"{nameof(sender)} is not of type {nameof(IWebSocketConnection)}", nameof(sender)); _logger.LogDebug("Removing websocket from session {Session}", _session.Id); _sockets.Remove(connection); connection.Closed -= OnConnectionClosed; @@ -65,7 +64,7 @@ namespace Emby.Server.Implementations.Session /// <inheritdoc /> public Task SendMessage<T>( - string name, + SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs b/Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs deleted file mode 100644 index ae1a8d0b7..000000000 --- a/Emby.Server.Implementations/SocketSharp/WebSocketSharpRequest.cs +++ /dev/null @@ -1,248 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Mime; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; -using Microsoft.Net.Http.Headers; -using IHttpRequest = MediaBrowser.Model.Services.IHttpRequest; - -namespace Emby.Server.Implementations.SocketSharp -{ - public class WebSocketSharpRequest : IHttpRequest - { - private const string FormUrlEncoded = "application/x-www-form-urlencoded"; - private const string MultiPartFormData = "multipart/form-data"; - private const string Soap11 = "text/xml; charset=utf-8"; - - private string _remoteIp; - private Dictionary<string, object> _items; - private string _responseContentType; - - public WebSocketSharpRequest(HttpRequest httpRequest, HttpResponse httpResponse, string operationName) - { - this.OperationName = operationName; - this.Request = httpRequest; - this.Response = httpResponse; - } - - public string Accept => StringValues.IsNullOrEmpty(Request.Headers[HeaderNames.Accept]) ? null : Request.Headers[HeaderNames.Accept].ToString(); - - public string Authorization => StringValues.IsNullOrEmpty(Request.Headers[HeaderNames.Authorization]) ? null : Request.Headers[HeaderNames.Authorization].ToString(); - - public HttpRequest Request { get; } - - public HttpResponse Response { get; } - - public string OperationName { get; set; } - - public string RawUrl => Request.GetEncodedPathAndQuery(); - - public string AbsoluteUri => Request.GetDisplayUrl().TrimEnd('/'); - - public string RemoteIp - { - get - { - if (_remoteIp != null) - { - return _remoteIp; - } - - IPAddress ip; - - // "Real" remote ip might be in X-Forwarded-For of X-Real-Ip - // (if the server is behind a reverse proxy for example) - if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XForwardedFor), out ip)) - { - if (!IPAddress.TryParse(GetHeader(CustomHeaderNames.XRealIP), out ip)) - { - ip = Request.HttpContext.Connection.RemoteIpAddress; - - // Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests) - ip ??= IPAddress.Loopback; - } - } - - return _remoteIp = NormalizeIp(ip).ToString(); - } - } - - public string[] AcceptTypes => Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept); - - public Dictionary<string, object> Items => _items ?? (_items = new Dictionary<string, object>()); - - public string ResponseContentType - { - get => - _responseContentType - ?? (_responseContentType = GetResponseContentType(Request)); - set => _responseContentType = value; - } - - public string PathInfo => Request.Path.Value; - - public string UserAgent => Request.Headers[HeaderNames.UserAgent]; - - public IHeaderDictionary Headers => Request.Headers; - - public IQueryCollection QueryString => Request.Query; - - public bool IsLocal => - (Request.HttpContext.Connection.LocalIpAddress == null - && Request.HttpContext.Connection.RemoteIpAddress == null) - || Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress); - - public string HttpMethod => Request.Method; - - public string Verb => HttpMethod; - - public string ContentType => Request.ContentType; - - public Uri UrlReferrer => Request.GetTypedHeaders().Referer; - - public Stream InputStream => Request.Body; - - public long ContentLength => Request.ContentLength ?? 0; - - private string GetHeader(string name) => Request.Headers[name].ToString(); - - private static IPAddress NormalizeIp(IPAddress ip) - { - if (ip.IsIPv4MappedToIPv6) - { - return ip.MapToIPv4(); - } - - return ip; - } - - public static string GetResponseContentType(HttpRequest httpReq) - { - var specifiedContentType = GetQueryStringContentType(httpReq); - if (!string.IsNullOrEmpty(specifiedContentType)) - { - return specifiedContentType; - } - - const string ServerDefaultContentType = MediaTypeNames.Application.Json; - - var acceptContentTypes = httpReq.Headers.GetCommaSeparatedValues(HeaderNames.Accept); - string defaultContentType = null; - if (HasAnyOfContentTypes(httpReq, FormUrlEncoded, MultiPartFormData)) - { - defaultContentType = ServerDefaultContentType; - } - - var acceptsAnything = false; - var hasDefaultContentType = defaultContentType != null; - if (acceptContentTypes != null) - { - foreach (ReadOnlySpan<char> acceptsType in acceptContentTypes) - { - ReadOnlySpan<char> contentType = acceptsType; - var index = contentType.IndexOf(';'); - if (index != -1) - { - contentType = contentType.Slice(0, index); - } - - contentType = contentType.Trim(); - acceptsAnything = contentType.Equals("*/*", StringComparison.OrdinalIgnoreCase); - - if (acceptsAnything) - { - break; - } - } - - if (acceptsAnything) - { - if (hasDefaultContentType) - { - return defaultContentType; - } - else - { - return ServerDefaultContentType; - } - } - } - - if (acceptContentTypes == null && httpReq.ContentType == Soap11) - { - return Soap11; - } - - // We could also send a '406 Not Acceptable', but this is allowed also - return ServerDefaultContentType; - } - - public static bool HasAnyOfContentTypes(HttpRequest request, params string[] contentTypes) - { - if (contentTypes == null || request.ContentType == null) - { - return false; - } - - foreach (var contentType in contentTypes) - { - if (IsContentType(request, contentType)) - { - return true; - } - } - - return false; - } - - public static bool IsContentType(HttpRequest request, string contentType) - { - return request.ContentType.StartsWith(contentType, StringComparison.OrdinalIgnoreCase); - } - - private static string GetQueryStringContentType(HttpRequest httpReq) - { - ReadOnlySpan<char> format = httpReq.Query["format"].ToString(); - if (format == ReadOnlySpan<char>.Empty) - { - const int FormatMaxLength = 4; - ReadOnlySpan<char> pi = httpReq.Path.ToString(); - if (pi == null || pi.Length <= FormatMaxLength) - { - return null; - } - - if (pi[0] == '/') - { - pi = pi.Slice(1); - } - - format = pi.LeftPart('/'); - if (format.Length > FormatMaxLength) - { - return null; - } - } - - format = format.LeftPart('.'); - if (format.Contains("json", StringComparison.OrdinalIgnoreCase)) - { - return "application/json"; - } - else if (format.Contains("xml", StringComparison.OrdinalIgnoreCase)) - { - return "application/xml"; - } - - return null; - } - } -} diff --git a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs index 1f68a9c81..2b0ab536f 100644 --- a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs +++ b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { @@ -131,11 +131,11 @@ namespace Emby.Server.Implementations.Sorting return GetSpecialCompareValue(x).CompareTo(GetSpecialCompareValue(y)); } - private static int GetSpecialCompareValue(Episode item) + private static long GetSpecialCompareValue(Episode item) { // First sort by season number // Since there are three sort orders, pad with 9 digits (3 for each, figure 1000 episode buffer should be enough) - var val = (item.AirsAfterSeasonNumber ?? item.AirsBeforeSeasonNumber ?? 0) * 1000000000; + var val = (item.AirsAfterSeasonNumber ?? item.AirsBeforeSeasonNumber ?? 0) * 1000000000L; // Second sort order is if it airs after the season if (item.AirsAfterSeasonNumber.HasValue) diff --git a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs index 7657cc74e..42e644970 100644 --- a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs @@ -18,7 +18,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); } @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static string GetValue(BaseItem x) + private static string? GetValue(BaseItem? x) { var audio = x as IHasAlbumArtist; diff --git a/Emby.Server.Implementations/Sorting/AlbumComparer.cs b/Emby.Server.Implementations/Sorting/AlbumComparer.cs index 7dfdd9ecf..1db3f5e9c 100644 --- a/Emby.Server.Implementations/Sorting/AlbumComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumComparer.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); } @@ -27,7 +27,7 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static string GetValue(BaseItem x) + private static string? GetValue(BaseItem? x) { var audio = x as Audio; diff --git a/Emby.Server.Implementations/Sorting/ArtistComparer.cs b/Emby.Server.Implementations/Sorting/ArtistComparer.cs index 756d3c5b6..98bee3fd9 100644 --- a/Emby.Server.Implementations/Sorting/ArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/ArtistComparer.cs @@ -15,7 +15,7 @@ namespace Emby.Server.Implementations.Sorting public string Name => ItemSortBy.Artist; /// <inheritdoc /> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); } @@ -25,7 +25,7 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static string GetValue(BaseItem x) + private static string? GetValue(BaseItem? x) { if (!(x is Audio audio)) { diff --git a/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs index 980954ba0..5f142fa4b 100644 --- a/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs @@ -21,7 +21,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs index fa136c36d..d20dedc2d 100644 --- a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs @@ -15,14 +15,14 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetValue(x).CompareTo(GetValue(y)); } - private static float GetValue(BaseItem x) + private static float GetValue(BaseItem? x) { - return x.CriticRating ?? 0; + return x?.CriticRating ?? 0; } /// <summary> diff --git a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs index cbca300d2..d3f10f78c 100644 --- a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index 03ff19d21..b1cb123ce 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs index 16bd2aff8..08a44319f 100644 --- a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs index 0c4e82d01..73e628cf7 100644 --- a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs @@ -1,3 +1,4 @@ +#nullable disable #pragma warning disable CS1591 using Jellyfin.Data.Entities; diff --git a/Emby.Server.Implementations/Sorting/IsFolderComparer.cs b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs index a35192eff..3c5ddeefa 100644 --- a/Emby.Server.Implementations/Sorting/IsFolderComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs @@ -20,7 +20,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetValue(x).CompareTo(GetValue(y)); } @@ -30,9 +30,9 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>System.String.</returns> - private static int GetValue(BaseItem x) + private static int GetValue(BaseItem? x) { - return x.IsFolder ? 0 : 1; + return x?.IsFolder ?? true ? 0 : 1; } } } diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs index d95948406..7d77a8bc5 100644 --- a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using Jellyfin.Data.Entities; diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs index 1632c5a7a..926835f90 100644 --- a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using Jellyfin.Data.Entities; diff --git a/Emby.Server.Implementations/Sorting/NameComparer.cs b/Emby.Server.Implementations/Sorting/NameComparer.cs index da020d8d8..4de81a69e 100644 --- a/Emby.Server.Implementations/Sorting/NameComparer.cs +++ b/Emby.Server.Implementations/Sorting/NameComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs index 76bb798b5..a81f78ebf 100644 --- a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs @@ -29,7 +29,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { if (x == null) { diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs index 5c2830322..04e4865cb 100644 --- a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs +++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs index 92ac04dc6..c98f97bf1 100644 --- a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetDate(x).CompareTo(GetDate(y)); } @@ -26,8 +26,13 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>DateTime.</returns> - private static DateTime GetDate(BaseItem x) + private static DateTime GetDate(BaseItem? x) { + if (x == null) + { + return DateTime.MinValue; + } + if (x.PremiereDate.HasValue) { return x.PremiereDate.Value; diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs index e2857df0b..df9f9957d 100644 --- a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs +++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs @@ -15,7 +15,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return GetValue(x).CompareTo(GetValue(y)); } @@ -25,8 +25,13 @@ namespace Emby.Server.Implementations.Sorting /// </summary> /// <param name="x">The x.</param> /// <returns>DateTime.</returns> - private static int GetValue(BaseItem x) + private static int GetValue(BaseItem? x) { + if (x == null) + { + return 0; + } + if (x.ProductionYear.HasValue) { return x.ProductionYear.Value; diff --git a/Emby.Server.Implementations/Sorting/RandomComparer.cs b/Emby.Server.Implementations/Sorting/RandomComparer.cs index 7739d0418..af3bc2750 100644 --- a/Emby.Server.Implementations/Sorting/RandomComparer.cs +++ b/Emby.Server.Implementations/Sorting/RandomComparer.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Sorting /// <param name="x">The x.</param> /// <param name="y">The y.</param> /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { return Guid.NewGuid().CompareTo(Guid.NewGuid()); } diff --git a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs index dde44333d..129315303 100644 --- a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs +++ b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs index b9205ee07..4123a59f8 100644 --- a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Sorting/SortNameComparer.cs b/Emby.Server.Implementations/Sorting/SortNameComparer.cs index f745e193b..8d30716d3 100644 --- a/Emby.Server.Implementations/Sorting/SortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SortNameComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; diff --git a/Emby.Server.Implementations/Sorting/StartDateComparer.cs b/Emby.Server.Implementations/Sorting/StartDateComparer.cs index 558a3d351..c3df7c47e 100644 --- a/Emby.Server.Implementations/Sorting/StartDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/StartDateComparer.cs @@ -1,3 +1,5 @@ +#nullable disable + #pragma warning disable CS1591 using System; diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 5766dc542..6826aee3b 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -1,7 +1,10 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Querying; @@ -28,7 +31,7 @@ namespace Emby.Server.Implementations.Sorting throw new ArgumentNullException(nameof(y)); } - return AlphanumComparator.CompareValues(x.Studios.FirstOrDefault() ?? string.Empty, y.Studios.FirstOrDefault() ?? string.Empty); + return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); } /// <summary> diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs new file mode 100644 index 000000000..12efff261 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -0,0 +1,676 @@ +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.SyncPlay +{ + /// <summary> + /// Class Group. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class Group : IGroupStateContext + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<Group> _logger; + + /// <summary> + /// The logger factory. + /// </summary> + private readonly ILoggerFactory _loggerFactory; + + /// <summary> + /// The user manager. + /// </summary> + private readonly IUserManager _userManager; + + /// <summary> + /// The session manager. + /// </summary> + private readonly ISessionManager _sessionManager; + + /// <summary> + /// The library manager. + /// </summary> + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// The participants, or members of the group. + /// </summary> + private readonly Dictionary<string, GroupMember> _participants = + new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// The internal group state. + /// </summary> + private IGroupState _state; + + /// <summary> + /// Initializes a new instance of the <see cref="Group" /> class. + /// </summary> + /// <param name="loggerFactory">The logger factory.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="sessionManager">The session manager.</param> + /// <param name="libraryManager">The library manager.</param> + public Group( + ILoggerFactory loggerFactory, + IUserManager userManager, + ISessionManager sessionManager, + ILibraryManager libraryManager) + { + _loggerFactory = loggerFactory; + _userManager = userManager; + _sessionManager = sessionManager; + _libraryManager = libraryManager; + _logger = loggerFactory.CreateLogger<Group>(); + + _state = new IdleGroupState(loggerFactory); + } + + /// <summary> + /// Gets the default ping value used for sessions. + /// </summary> + /// <value>The default ping.</value> + public long DefaultPing { get; } = 500; + + /// <summary> + /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum time offset error.</value> + public long TimeSyncOffset { get; } = 2000; + + /// <summary> + /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum offset error.</value> + public long MaxPlaybackOffset { get; } = 500; + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public Guid GroupId { get; } = Guid.NewGuid(); + + /// <summary> + /// Gets the group name. + /// </summary> + /// <value>The group name.</value> + public string GroupName { get; private set; } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); + + /// <summary> + /// Gets the runtime ticks of current playing item. + /// </summary> + /// <value>The runtime ticks of current playing item.</value> + public long RunTimeTicks { get; private set; } + + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } + + /// <summary> + /// Gets or sets the last activity. + /// </summary> + /// <value>The last activity.</value> + public DateTime LastActivity { get; set; } + + /// <summary> + /// Adds the session to the group. + /// </summary> + /// <param name="session">The session.</param> + private void AddSession(SessionInfo session) + { + _participants.TryAdd( + session.Id, + new GroupMember(session) + { + Ping = DefaultPing, + IsBuffering = false + }); + } + + /// <summary> + /// Removes the session from the group. + /// </summary> + /// <param name="session">The session.</param> + private void RemoveSession(SessionInfo session) + { + _participants.Remove(session.Id); + } + + /// <summary> + /// Filters sessions of this group. + /// </summary> + /// <param name="from">The current session.</param> + /// <param name="type">The filtering type.</param> + /// <returns>The list of sessions matching the filter.</returns> + private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, SyncPlayBroadcastType type) + { + return type switch + { + SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from }, + SyncPlayBroadcastType.AllGroup => _participants + .Values + .Select(session => session.Session), + SyncPlayBroadcastType.AllExceptCurrentSession => _participants + .Values + .Select(session => session.Session) + .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)), + SyncPlayBroadcastType.AllReady => _participants + .Values + .Where(session => !session.IsBuffering) + .Select(session => session.Session), + _ => Enumerable.Empty<SessionInfo>() + }; + } + + /// <summary> + /// Checks if a given user can access all items of a given queue, that is, + /// the user has the required minimum parental access and has access to all required folders. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="queue">The queue.</param> + /// <returns><c>true</c> if the user can access all the items in the queue, <c>false</c> otherwise.</returns> + private bool HasAccessToQueue(User user, IReadOnlyList<Guid> queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + foreach (var itemId in queue) + { + var item = _libraryManager.GetItemById(itemId); + if (!item.IsVisibleStandalone(user)) + { + return false; + } + } + + return true; + } + + private bool AllUsersHaveAccessToQueue(IReadOnlyList<Guid> queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + // Get list of users. + var users = _participants + .Values + .Select(participant => _userManager.GetUserById(participant.Session.UserId)); + + // Find problematic users. + var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); + + // All users must be able to access the queue. + return !usersWithNoAccess.Any(); + } + + /// <summary> + /// Checks if the group is empty. + /// </summary> + /// <returns><c>true</c> if the group is empty, <c>false</c> otherwise.</returns> + public bool IsGroupEmpty() => _participants.Count == 0; + + /// <summary> + /// Initializes the group with the session's info. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + GroupName = request.GroupName; + AddSession(session); + + var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; + + RestartCurrentItem(); + + if (sessionIsPlayingAnItem) + { + var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList(); + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playlist); + PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); + RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; + PositionTicks = session.PlayState.PositionTicks ?? 0; + + // Maintain playstate. + var waitingState = new WaitingGroupState(_loggerFactory) + { + ResumePlaying = !session.PlayState.IsPaused + }; + SetState(waitingState); + } + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <summary> + /// Adds the session to the group. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + AddSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <summary> + /// Removes the session from the group. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void SessionLeave(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) + { + _state.SessionLeaving(this, _state.Type, session, cancellationToken); + + RemoveSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <summary> + /// Handles the requested action by the session. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The requested action.</param> + /// <param name="cancellationToken">The cancellation token.</param> + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) + { + // The server's job is to maintain a consistent state for clients to reference + // and notify clients of state changes. The actual syncing of media playback + // happens client side. Clients are aware of the server's time and use it to sync. + _logger.LogInformation("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Action, GroupId.ToString(), _state.Type); + + // Apply requested changes to this group given its current state. + // Every request has a slightly different outcome depending on the group's state. + // There are currently four different group states that accomplish different goals: + // - Idle: in this state no media is playing and clients should be idle (playback is stopped). + // - Waiting: in this state the group is waiting for all the clients to be ready to start the playback, + // that is, they've either finished loading the media for the first time or they've finished buffering. + // Once all clients report to be ready the group's state can change to Playing or Paused. + // - Playing: clients have some media loaded and playback is unpaused. + // - Paused: clients have some media loaded but playback is currently paused. + request.Apply(this, _state, session, cancellationToken); + } + + /// <summary> + /// Gets the info about the group for the clients. + /// </summary> + /// <returns>The group info for the clients.</returns> + public GroupInfoDto GetInfo() + { + var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList(); + return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow); + } + + /// <summary> + /// Checks if a user has access to all content in the play queue. + /// </summary> + /// <param name="user">The user.</param> + /// <returns><c>true</c> if the user can access the play queue; <c>false</c> otherwise.</returns> + public bool HasAccessToPlayQueue(User user) + { + var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList(); + return HasAccessToQueue(user, items); + } + + /// <inheritdoc /> + public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IgnoreGroupWait = ignoreGroupWait; + } + } + + /// <inheritdoc /> + public void SetState(IGroupState state) + { + _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type); + this._state = state; + } + + /// <inheritdoc /> + public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// <inheritdoc /> + public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// <inheritdoc /> + public SendCommand NewSyncPlayCommand(SendCommandType type) + { + return new SendCommand( + GroupId, + PlayQueue.GetPlayingItemPlaylistId(), + LastActivity, + type, + PositionTicks, + DateTime.UtcNow); + } + + /// <inheritdoc /> + public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) + { + return new GroupUpdate<T>(GroupId, type, data); + } + + /// <inheritdoc /> + public long SanitizePositionTicks(long? positionTicks) + { + var ticks = positionTicks ?? 0; + return Math.Clamp(ticks, 0, RunTimeTicks); + } + + /// <inheritdoc /> + public void UpdatePing(SessionInfo session, long ping) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.Ping = ping; + } + } + + /// <inheritdoc /> + public long GetHighestPing() + { + long max = long.MinValue; + foreach (var session in _participants.Values) + { + max = Math.Max(max, session.Ping); + } + + return max; + } + + /// <inheritdoc /> + public void SetBuffering(SessionInfo session, bool isBuffering) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IsBuffering = isBuffering; + } + } + + /// <inheritdoc /> + public void SetAllBuffering(bool isBuffering) + { + foreach (var session in _participants.Values) + { + session.IsBuffering = isBuffering; + } + } + + /// <inheritdoc /> + public bool IsBuffering() + { + foreach (var session in _participants.Values) + { + if (session.IsBuffering && !session.IgnoreGroupWait) + { + return true; + } + } + + return false; + } + + /// <inheritdoc /> + public bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks) + { + // Ignore on empty queue or invalid item position. + if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(playQueue)) + { + return false; + } + + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playQueue); + PlayQueue.SetPlayingItemByIndex(playingItemPosition); + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + PositionTicks = startPositionTicks; + LastActivity = DateTime.UtcNow; + + return true; + } + + /// <inheritdoc /> + public bool SetPlayingItem(Guid playlistItemId) + { + var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId); + + if (itemFound) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + + return itemFound; + } + + /// <inheritdoc /> + public bool RemoveFromPlayQueue(IReadOnlyList<Guid> playlistItemIds) + { + var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds); + if (playingItemRemoved) + { + var itemId = PlayQueue.GetPlayingItemId(); + if (!itemId.Equals(Guid.Empty)) + { + var item = _libraryManager.GetItemById(itemId); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + } + + return playingItemRemoved; + } + + /// <inheritdoc /> + public bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex) + { + return PlayQueue.MovePlaylistItem(playlistItemId, newIndex); + } + + /// <inheritdoc /> + public bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode) + { + // Ignore on empty list. + if (newItems.Count == 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(newItems)) + { + return false; + } + + if (mode.Equals(GroupQueueMode.QueueNext)) + { + PlayQueue.QueueNext(newItems); + } + else + { + PlayQueue.Queue(newItems); + } + + return true; + } + + /// <inheritdoc /> + public void RestartCurrentItem() + { + PositionTicks = 0; + LastActivity = DateTime.UtcNow; + } + + /// <inheritdoc /> + public bool NextItemInQueue() + { + var update = PlayQueue.Next(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// <inheritdoc /> + public bool PreviousItemInQueue() + { + var update = PlayQueue.Previous(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// <inheritdoc /> + public void SetRepeatMode(GroupRepeatMode mode) + { + PlayQueue.SetRepeatMode(mode); + } + + /// <inheritdoc /> + public void SetShuffleMode(GroupShuffleMode mode) + { + PlayQueue.SetShuffleMode(mode); + } + + /// <inheritdoc /> + public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) + { + var startPositionTicks = PositionTicks; + + if (_state.Type.Equals(GroupStateType.Playing)) + { + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - LastActivity; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Adjust ticks only if playback actually started. + startPositionTicks += Math.Max(elapsedTime.Ticks, 0); + } + + return new PlayQueueUpdate( + reason, + PlayQueue.LastChange, + PlayQueue.GetPlaylist(), + PlayQueue.PlayingItemIndex, + startPositionTicks, + PlayQueue.ShuffleMode, + PlayQueue.RepeatMode); + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs deleted file mode 100644 index 80b977731..000000000 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs +++ /dev/null @@ -1,516 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace Emby.Server.Implementations.SyncPlay -{ - /// <summary> - /// Class SyncPlayController. - /// </summary> - /// <remarks> - /// Class is not thread-safe, external locking is required when accessing methods. - /// </remarks> - public class SyncPlayController : ISyncPlayController - { - /// <summary> - /// Used to filter the sessions of a group. - /// </summary> - private enum BroadcastType - { - /// <summary> - /// All sessions will receive the message. - /// </summary> - AllGroup = 0, - - /// <summary> - /// Only the specified session will receive the message. - /// </summary> - CurrentSession = 1, - - /// <summary> - /// All sessions, except the current one, will receive the message. - /// </summary> - AllExceptCurrentSession = 2, - - /// <summary> - /// Only sessions that are not buffering will receive the message. - /// </summary> - AllReady = 3 - } - - /// <summary> - /// The session manager. - /// </summary> - private readonly ISessionManager _sessionManager; - - /// <summary> - /// The SyncPlay manager. - /// </summary> - private readonly ISyncPlayManager _syncPlayManager; - - /// <summary> - /// The group to manage. - /// </summary> - private readonly GroupInfo _group = new GroupInfo(); - - /// <summary> - /// Initializes a new instance of the <see cref="SyncPlayController" /> class. - /// </summary> - /// <param name="sessionManager">The session manager.</param> - /// <param name="syncPlayManager">The SyncPlay manager.</param> - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - } - - /// <inheritdoc /> - public Guid GetGroupId() => _group.GroupId; - - /// <inheritdoc /> - public Guid GetPlayingItemId() => _group.PlayingItem.Id; - - /// <inheritdoc /> - public bool IsGroupEmpty() => _group.IsEmpty(); - - /// <summary> - /// Converts DateTime to UTC string. - /// </summary> - /// <param name="date">The date to convert.</param> - /// <value>The UTC string.</value> - private string DateToUTCString(DateTime date) - { - return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - } - - /// <summary> - /// Filters sessions of this group. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <value>The array of sessions matching the filter.</value> - private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type) - { - switch (type) - { - case BroadcastType.CurrentSession: - return new SessionInfo[] { from }; - case BroadcastType.AllGroup: - return _group.Participants.Values - .Select(session => session.Session); - case BroadcastType.AllExceptCurrentSession: - return _group.Participants.Values - .Select(session => session.Session) - .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal)); - case BroadcastType.AllReady: - return _group.Participants.Values - .Where(session => !session.IsBuffering) - .Select(session => session.Session); - default: - return Array.Empty<SessionInfo>(); - } - } - - /// <summary> - /// Sends a GroupUpdate message to the interested sessions. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <param name="message">The message to send.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <value>The task.</value> - private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <summary> - /// Sends a playback command to the interested sessions. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <param name="message">The message to send.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <value>The task.</value> - private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <summary> - /// Builds a new playback command with some default values. - /// </summary> - /// <param name="type">The command type.</param> - /// <value>The SendCommand.</value> - private SendCommand NewSyncPlayCommand(SendCommandType type) - { - return new SendCommand() - { - GroupId = _group.GroupId.ToString(), - Command = type, - PositionTicks = _group.PositionTicks, - When = DateToUTCString(_group.LastActivity), - EmittedAt = DateToUTCString(DateTime.UtcNow) - }; - } - - /// <summary> - /// Builds a new group update message. - /// </summary> - /// <param name="type">The update type.</param> - /// <param name="data">The data to send.</param> - /// <value>The GroupUpdate.</value> - private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) - { - return new GroupUpdate<T>() - { - GroupId = _group.GroupId.ToString(), - Type = type, - Data = data - }; - } - - /// <inheritdoc /> - public void CreateGroup(SessionInfo session, CancellationToken cancellationToken) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - _group.PlayingItem = session.FullNowPlayingItem; - _group.IsPaused = session.PlayState.IsPaused; - _group.PositionTicks = session.PlayState.PositionTicks ?? 0; - _group.LastActivity = DateTime.UtcNow; - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - } - - /// <inheritdoc /> - public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) - { - if (session.NowPlayingItem?.Id == _group.PlayingItem.Id) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - - // Syncing will happen client-side - if (!_group.IsPaused) - { - var playCommand = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken); - } - else - { - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken); - } - } - else - { - var playRequest = new PlayRequest - { - ItemIds = new Guid[] { _group.PlayingItem.Id }, - StartPositionTicks = _group.PositionTicks - }; - var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest); - SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken); - } - } - - /// <inheritdoc /> - public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) - { - _group.RemoveSession(session); - _syncPlayManager.RemoveSessionFromGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - - /// <inheritdoc /> - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - // The server's job is to maintain a consistent state for clients to reference - // and notify clients of state changes. The actual syncing of media playback - // happens client side. Clients are aware of the server's time and use it to sync. - switch (request.Type) - { - case PlaybackRequestType.Play: - HandlePlayRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Pause: - HandlePauseRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Seek: - HandleSeekRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Buffer: - HandleBufferingRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ready: - HandleBufferingDoneRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ping: - HandlePingUpdateRequest(session, request); - break; - } - } - - /// <summary> - /// Handles a play action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The play action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - // Pick a suitable time that accounts for latency - var delay = _group.GetHighestPing() * 2; - delay = delay < _group.DefaultPing ? _group.DefaultPing : delay; - - // Unpause group and set starting point in future - // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position) - // The added delay does not guarantee, of course, that the command will be received in time - // Playback synchronization will mainly happen client side - _group.IsPaused = false; - _group.LastActivity = DateTime.UtcNow.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a pause action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The pause action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (!_group.IsPaused) - { - // Pause group and compute the media playback position - _group.IsPaused = true; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - _group.LastActivity; - _group.LastActivity = currentTime; - - // Seek only if playback actually started - // Pause request may be issued during the delay added to account for latency - _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a seek action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The seek action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - // Sanitize PositionTicks - var ticks = SanitizePositionTicks(request.PositionTicks); - - // Pause and seek - _group.IsPaused = true; - _group.PositionTicks = ticks; - _group.LastActivity = DateTime.UtcNow; - - var command = NewSyncPlayCommand(SendCommandType.Seek); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - - /// <summary> - /// Handles a buffering action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The buffering action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (!_group.IsPaused) - { - // Pause group and compute the media playback position - _group.IsPaused = true; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - _group.LastActivity; - _group.LastActivity = currentTime; - _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - _group.SetBuffering(session, true); - - // Send pause command to all non-buffering sessions - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.AllReady, command, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a buffering-done action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The buffering-done action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - _group.SetBuffering(session, false); - - var requestTicks = SanitizePositionTicks(request.PositionTicks); - - var when = request.When ?? DateTime.UtcNow; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - when; - var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; - var delay = _group.PositionTicks - clientPosition.Ticks; - - if (_group.IsBuffering()) - { - // Others are still buffering, tell this client to pause when ready - var command = NewSyncPlayCommand(SendCommandType.Pause); - var pauseAtTime = currentTime.AddMilliseconds(delay); - command.When = DateToUTCString(pauseAtTime); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - else - { - // Let other clients resume as soon as the buffering client catches up - _group.IsPaused = false; - - if (delay > _group.GetHighestPing() * 2) - { - // Client that was buffering is recovering, notifying others to resume - _group.LastActivity = currentTime.AddMilliseconds( - delay); - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken); - } - else - { - // Client, that was buffering, resumed playback but did not update others in time - delay = _group.GetHighestPing() * 2; - delay = delay < _group.DefaultPing ? _group.DefaultPing : delay; - - _group.LastActivity = currentTime.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - } - } - else - { - // Group was not waiting, make sure client has latest state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Sanitizes the PositionTicks, considers the current playing item when available. - /// </summary> - /// <param name="positionTicks">The PositionTicks.</param> - /// <value>The sanitized PositionTicks.</value> - private long SanitizePositionTicks(long? positionTicks) - { - var ticks = positionTicks ?? 0; - ticks = ticks >= 0 ? ticks : 0; - if (_group.PlayingItem != null) - { - var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0; - ticks = ticks > runTimeTicks ? runTimeTicks : ticks; - } - - return ticks; - } - - /// <summary> - /// Updates ping of a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The update.</param> - private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request) - { - // Collected pings are used to account for network latency when unpausing playback - _group.UpdatePing(session, request.Ping ?? _group.DefaultPing); - } - - /// <inheritdoc /> - public GroupInfoView GetInfo() - { - return new GroupInfoView() - { - GroupId = GetGroupId().ToString(), - PlayingItemName = _group.PlayingItem.Name, - PlayingItemId = _group.PlayingItem.Id.ToString(), - PositionTicks = _group.PositionTicks, - Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList() - }; - } - } -} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 966ed5024..993456196 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -1,13 +1,13 @@ +#nullable disable + using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using System.Threading; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.Extensions.Logging; @@ -24,6 +24,11 @@ namespace Emby.Server.Implementations.SyncPlay private readonly ILogger<SyncPlayManager> _logger; /// <summary> + /// The logger factory. + /// </summary> + private readonly ILoggerFactory _loggerFactory; + + /// <summary> /// The user manager. /// </summary> private readonly IUserManager _userManager; @@ -39,20 +44,29 @@ 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 Dictionary<string, ISyncPlayController> _sessionToGroupMap = - new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, Group> _sessionToGroupMap = + new ConcurrentDictionary<string, Group>(StringComparer.OrdinalIgnoreCase); /// <summary> /// The groups. /// </summary> - private readonly Dictionary<Guid, ISyncPlayController> _groups = - new Dictionary<Guid, ISyncPlayController>(); + private readonly ConcurrentDictionary<Guid, Group> _groups = + new ConcurrentDictionary<Guid, Group>(); /// <summary> - /// Lock used for accesing any group. + /// Lock used for accessing multiple groups at once. /// </summary> + /// <remarks> + /// This lock has priority on locks made on <see cref="Group"/>. + /// </remarks> private readonly object _groupsLock = new object(); private bool _disposed = false; @@ -60,31 +74,24 @@ namespace Emby.Server.Implementations.SyncPlay /// <summary> /// Initializes a new instance of the <see cref="SyncPlayManager" /> class. /// </summary> - /// <param name="logger">The logger.</param> + /// <param name="loggerFactory">The logger factory.</param> /// <param name="userManager">The user manager.</param> /// <param name="sessionManager">The session manager.</param> /// <param name="libraryManager">The library manager.</param> public SyncPlayManager( - ILogger<SyncPlayManager> logger, + ILoggerFactory loggerFactory, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager) { - _logger = logger; + _loggerFactory = loggerFactory; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; - - _sessionManager.SessionEnded += OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; + _logger = loggerFactory.CreateLogger<SyncPlayManager>(); + _sessionManager.SessionEnded += OnSessionEnded; } - /// <summary> - /// Gets all groups. - /// </summary> - /// <value>All groups.</value> - public IEnumerable<ISyncPlayController> Groups => _groups.Values; - /// <inheritdoc /> public void Dispose() { @@ -92,286 +99,297 @@ namespace Emby.Server.Implementations.SyncPlay GC.SuppressFinalize(this); } - /// <summary> - /// Releases unmanaged and optionally managed resources. - /// </summary> - /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool disposing) + /// <inheritdoc /> + public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { - if (_disposed) + if (session == null) { - return; + throw new InvalidOperationException("Session is null!"); } - _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; - - _disposed = true; - } - - private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) - { - var session = e.SessionInfo; - if (!IsSessionInGroup(session)) + if (request == null) { - return; + throw new InvalidOperationException("Request is null!"); } - LeaveGroup(session, CancellationToken.None); - } - - private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) - { - var session = e.Session; - if (!IsSessionInGroup(session)) + // Locking required to access list of groups. + lock (_groupsLock) { - return; - } - - LeaveGroup(session, CancellationToken.None); - } - - private bool IsSessionInGroup(SessionInfo session) - { - return _sessionToGroupMap.ContainsKey(session.Id); - } - - private bool HasAccessToItem(User user, Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); + // Make sure that session has not joined another group. + if (_sessionToGroupMap.ContainsKey(session.Id)) + { + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, cancellationToken); + } - // Check ParentalRating access - var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue - || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating; + var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager); + _groups[group.GroupId] = group; - if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) - { - var collections = _libraryManager.GetCollectionFolders(item).Select( - folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); + if (!_sessionToGroupMap.TryAdd(session.Id, group)) + { + throw new InvalidOperationException("Could not add session to group!"); + } - return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); + UpdateSessionsCounter(session.UserId, 1); + group.CreateGroup(session, request, cancellationToken); } - - return hasParentalRatingAccess; - } - - private Guid? GetSessionGroup(SessionInfo session) - { - _sessionToGroupMap.TryGetValue(session.Id, out var group); - return group?.GetGroupId(); } /// <inheritdoc /> - public void NewGroup(SessionInfo session, CancellationToken cancellationToken) + public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups) + if (session == null) { - _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id); - - var error = new GroupUpdate<string> - { - Type = GroupUpdateType.CreateGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + throw new InvalidOperationException("Session is null!"); } - lock (_groupsLock) + if (request == null) { - if (IsSessionInGroup(session)) - { - LeaveGroup(session, cancellationToken); - } - - var group = new SyncPlayController(_sessionManager, this); - _groups[group.GetGroupId()] = group; - - group.CreateGroup(session, cancellationToken); + throw new InvalidOperationException("Request is null!"); } - } - /// <inheritdoc /> - public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken) - { var user = _userManager.GetUserById(session.UserId); - if (user.SyncPlayAccess == SyncPlayAccess.None) - { - _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id); - - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.JoinGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - + // Locking required to access list of groups. lock (_groupsLock) { - ISyncPlayController group; - _groups.TryGetValue(groupId, out group); + _groups.TryGetValue(request.GroupId, out Group group); if (group == null) { - _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId); + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId); - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.GroupDoesNotExist - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } - if (!HasAccessToItem(user, group.GetPlayingItemId())) + // Group lock required to let other requests end first. + lock (group) { - _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId()); + if (!group.HasAccessToPlayQueue(user)) + { + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString()); - var error = new GroupUpdate<string>() + var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } + + if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup)) { - GroupId = group.GetGroupId().ToString(), - Type = GroupUpdateType.LibraryAccessDenied - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } + if (existingGroup.GroupId.Equals(request.GroupId)) + { + // Restore session. + UpdateSessionsCounter(session.UserId, 1); + group.SessionJoin(session, request, cancellationToken); + return; + } + + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, cancellationToken); + } - if (IsSessionInGroup(session)) - { - if (GetSessionGroup(session).Equals(groupId)) + if (!_sessionToGroupMap.TryAdd(session.Id, group)) { - return; + throw new InvalidOperationException("Could not add session to group!"); } - LeaveGroup(session, cancellationToken); + UpdateSessionsCounter(session.UserId, 1); + group.SessionJoin(session, request, cancellationToken); } - - group.SessionJoin(session, request, cancellationToken); } } /// <inheritdoc /> - public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken) + public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) { - // TODO: determine what happens to users that are in a group and get their permissions revoked - lock (_groupsLock) + if (session == null) { - _sessionToGroupMap.TryGetValue(session.Id, out var group); + throw new InvalidOperationException("Session is null!"); + } - if (group == null) - { - _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id); + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } - var error = new GroupUpdate<string>() + // Locking required to access list of groups. + lock (_groupsLock) + { + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + // Group lock required to let other requests end first. + lock (group) { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup)) + { + if (!tempGroup.GroupId.Equals(group.GroupId)) + { + throw new InvalidOperationException("Session was in wrong group!"); + } + } + else + { + throw new InvalidOperationException("Could not remove session from group!"); + } + + UpdateSessionsCounter(session.UserId, -1); + group.SessionLeave(session, request, cancellationToken); + + if (group.IsGroupEmpty()) + { + _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); + _groups.Remove(group.GroupId, out _); + } + } } - - group.SessionLeave(session, cancellationToken); - - if (group.IsGroupEmpty()) + else { - _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId()); - _groups.Remove(group.GetGroupId(), out _); + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); + + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; } } } /// <inheritdoc /> - public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId) + public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) + if (session == null) { - return new List<GroupInfoView>(); + throw new InvalidOperationException("Session is null!"); } - // Filter by item if requested - if (!filterItemId.Equals(Guid.Empty)) + if (request == null) { - return _groups.Values.Where( - group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); + throw new InvalidOperationException("Request is null!"); } - else + + var user = _userManager.GetUserById(session.UserId); + List<GroupInfoDto> list = new List<GroupInfoDto>(); + + lock (_groupsLock) { - // Otherwise show all available groups - return _groups.Values.Where( - group => HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); + foreach (var (_, group) in _groups) + { + // Locking required as group is not thread-safe. + lock (group) + { + if (group.HasAccessToPlayQueue(user)) + { + list.Add(group.GetInfo()); + } + } + } } + + return list; } /// <inheritdoc /> - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) + if (session == null) { - _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id); - - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.JoinGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + throw new InvalidOperationException("Session is null!"); } - lock (_groupsLock) + if (request == null) { - _sessionToGroupMap.TryGetValue(session.Id, out var group); + throw new InvalidOperationException("Request is null!"); + } - if (group == null) + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + // Group lock required as Group is not thread-safe. + lock (group) { - _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id); + // Make sure that session still belongs to this group. + if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) + { + // Drop request. + return; + } - var error = new GroupUpdate<string>() + // Drop request if group is empty. + if (group.IsGroupEmpty()) { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + return; + } + + // Apply requested changes to group. + group.HandleRequest(session, request, cancellationToken); } + } + else + { + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); - group.HandleRequest(session, request, cancellationToken); + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); } } /// <inheritdoc /> - public void AddSessionToGroup(SessionInfo session, ISyncPlayController group) + public bool IsUserActive(Guid userId) { - if (IsSessionInGroup(session)) + if (_activeUsers.TryGetValue(userId, out var sessionsCounter)) { - throw new InvalidOperationException("Session in other group already!"); + return sessionsCounter > 0; } + else + { + return false; + } + } - _sessionToGroupMap[session.Id] = group; + /// <summary> + /// Releases unmanaged and optionally managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _sessionManager.SessionEnded -= OnSessionEnded; + _disposed = true; } - /// <inheritdoc /> - public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group) + private void OnSessionEnded(object sender, SessionEventArgs e) + { + var session = e.SessionInfo; + + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, CancellationToken.None); + } + } + + private void UpdateSessionsCounter(Guid userId, int toAdd) { - if (!IsSessionInGroup(session)) + // Update sessions counter. + var newSessionsCounter = _activeUsers.AddOrUpdate( + userId, + 1, + (key, sessionsCounter) => sessionsCounter + toAdd); + + // Should never happen. + if (newSessionsCounter < 0) { - throw new InvalidOperationException("Session not in any group!"); + throw new InvalidOperationException("Sessions counter is negative!"); } - _sessionToGroupMap.Remove(session.Id, out var tempGroup); - if (!tempGroup.GetGroupId().Equals(group.GetGroupId())) + // Clean record if user has no more active sessions. + if (newSessionsCounter == 0) { - throw new InvalidOperationException("Session was in wrong group!"); + _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 d1818deff..af453d148 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -1,8 +1,9 @@ +#nullable disable + #pragma warning disable CS1591 using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; @@ -11,7 +12,6 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.TV; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Series = MediaBrowser.Controller.Entities.TV.Series; @@ -23,12 +23,14 @@ namespace Emby.Server.Implementations.TV private readonly IUserManager _userManager; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; - public TVSeriesManager(IUserManager userManager, IUserDataManager userDataManager, ILibraryManager libraryManager) + public TVSeriesManager(IUserManager userManager, IUserDataManager userDataManager, ILibraryManager libraryManager, IServerConfigurationManager configurationManager) { _userManager = userManager; _userDataManager = userDataManager; _libraryManager = libraryManager; + _configurationManager = configurationManager; } public QueryResult<BaseItem> GetNextUp(NextUpQuery request, DtoOptions dtoOptions) @@ -43,9 +45,7 @@ namespace Emby.Server.Implementations.TV string presentationUniqueKey = null; if (!string.IsNullOrEmpty(request.SeriesId)) { - var series = _libraryManager.GetItemById(request.SeriesId) as Series; - - if (series != null) + if (_libraryManager.GetItemById(request.SeriesId) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); } @@ -56,13 +56,11 @@ namespace Emby.Server.Implementations.TV return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request); } - var parentIdGuid = string.IsNullOrEmpty(request.ParentId) ? (Guid?)null : new Guid(request.ParentId); - BaseItem[] parents; - if (parentIdGuid.HasValue) + if (request.ParentId.HasValue) { - var parent = _libraryManager.GetItemById(parentIdGuid.Value); + var parent = _libraryManager.GetItemById(request.ParentId.Value); if (parent != null) { @@ -77,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(); } @@ -98,9 +95,7 @@ namespace Emby.Server.Implementations.TV int? limit = null; if (!string.IsNullOrEmpty(request.SeriesId)) { - var series = _libraryManager.GetItemById(request.SeriesId) as Series; - - if (series != null) + if (_libraryManager.GetItemById(request.SeriesId) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); limit = 1; @@ -121,7 +116,7 @@ namespace Emby.Server.Implementations.TV .GetItemList( new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DatePlayed, SortOrder.Descending) }, SeriesPresentationUniqueKey = presentationUniqueKey, Limit = limit, @@ -146,8 +141,6 @@ namespace Emby.Server.Implementations.TV var allNextUp = seriesKeys .Select(i => GetNextUp(i, currentUser, dtoOptions)); - // allNextUp = allNextUp.OrderByDescending(i => i.Item1); - // If viewing all next up for all series, remove first episodes // But if that returns empty, keep those first episodes (avoid completely empty view) var alwaysEnableFirstEpisode = !string.IsNullOrEmpty(request.SeriesId); @@ -156,7 +149,12 @@ namespace Emby.Server.Implementations.TV return allNextUp .Where(i => { - if (alwaysEnableFirstEpisode || i.Item1 != DateTime.MinValue) + if (request.DisableFirstEpisode) + { + return i.Item1 != DateTime.MinValue; + } + + if (alwaysEnableFirstEpisode || (i.Item1 != DateTime.MinValue && i.Item1.Date >= request.NextUpDateCutoff)) { anyFound = true; return true; @@ -200,21 +198,18 @@ namespace Emby.Server.Implementations.TV ParentIndexNumberNotEquals = 0, DtoOptions = new DtoOptions { - Fields = new ItemFields[] - { - ItemFields.SortName - }, + Fields = new[] { ItemFields.SortName }, EnableImages = false } - }).FirstOrDefault(); + }).Cast<Episode>().FirstOrDefault(); Func<Episode> getEpisode = () => { - return _libraryManager.GetItemList(new InternalItemsQuery(user) + var nextEpisode = _libraryManager.GetItemList(new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) }, Limit = 1, IsPlayed = false, @@ -223,6 +218,55 @@ namespace Emby.Server.Implementations.TV MinSortName = lastWatchedEpisode?.SortName, DtoOptions = dtoOptions }).Cast<Episode>().FirstOrDefault(); + + if (_configurationManager.Configuration.DisplaySpecialsWithinSeasons) + { + var consideredEpisodes = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + AncestorWithPresentationUniqueKey = null, + SeriesPresentationUniqueKey = seriesKey, + ParentIndexNumber = 0, + IncludeItemTypes = new[] { nameof(Episode) }, + IsPlayed = false, + IsVirtualItem = false, + DtoOptions = dtoOptions + }) + .Cast<Episode>() + .Where(episode => episode.AirsBeforeSeasonNumber != null || episode.AirsAfterSeasonNumber != null) + .ToList(); + + if (lastWatchedEpisode != null) + { + // Last watched episode is added, because there could be specials that aired before the last watched episode + consideredEpisodes.Add(lastWatchedEpisode); + } + + if (nextEpisode != null) + { + consideredEpisodes.Add(nextEpisode); + } + + var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, new[] { (ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending) }) + .Cast<Episode>(); + if (lastWatchedEpisode != null) + { + sortedConsideredEpisodes = sortedConsideredEpisodes.SkipWhile(episode => episode.Id != lastWatchedEpisode.Id).Skip(1); + } + + nextEpisode = sortedConsideredEpisodes.FirstOrDefault(); + } + + if (nextEpisode != null) + { + var userData = _userDataManager.GetUserData(user, nextEpisode); + + if (userData.PlaybackPositionTicks > 0) + { + return null; + } + } + + return nextEpisode; }; if (lastWatchedEpisode != null) diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index 73fcbcec3..8179e26c5 100644 --- a/Emby.Server.Implementations/Udp/UdpServer.cs +++ b/Emby.Server.Implementations/Udp/UdpServer.cs @@ -18,17 +18,17 @@ namespace Emby.Server.Implementations.Udp public sealed class UdpServer : IDisposable { /// <summary> + /// Address Override Configuration Key. + /// </summary> + public const string AddressOverrideConfigKey = "PublishedServerUrl"; + + /// <summary> /// The _logger. /// </summary> private readonly ILogger _logger; private readonly IServerApplicationHost _appHost; private readonly IConfiguration _config; - /// <summary> - /// Address Override Configuration Key. - /// </summary> - public const string AddressOverrideConfigKey = "PublishedServerUrl"; - private Socket _udpSocket; private IPEndPoint _endpoint; private readonly byte[] _receiveBuffer = new byte[8192]; @@ -38,60 +38,58 @@ namespace Emby.Server.Implementations.Udp /// <summary> /// Initializes a new instance of the <see cref="UdpServer" /> class. /// </summary> - public UdpServer(ILogger logger, IServerApplicationHost appHost, IConfiguration configuration) + /// <param name="logger">The logger.</param> + /// <param name="appHost">The application host.</param> + /// <param name="configuration">The configuration manager.</param> + /// <param name="port">The port.</param> + public UdpServer( + ILogger logger, + IServerApplicationHost appHost, + IConfiguration configuration, + int port) { _logger = logger; _appHost = appHost; _config = configuration; + + _endpoint = new IPEndPoint(IPAddress.Any, port); + + _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); } private async Task RespondToV2Message(string messageText, EndPoint endpoint, CancellationToken cancellationToken) { - string localUrl = !string.IsNullOrEmpty(_config[AddressOverrideConfigKey]) - ? _config[AddressOverrideConfigKey] - : await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); + string? localUrl = _config[AddressOverrideConfigKey]; + if (string.IsNullOrEmpty(localUrl)) + { + localUrl = _appHost.GetSmartApiUrl(((IPEndPoint)endpoint).Address); + } - if (!string.IsNullOrEmpty(localUrl)) + if (string.IsNullOrEmpty(localUrl)) { - var response = new ServerDiscoveryInfo - { - Address = localUrl, - Id = _appHost.SystemId, - Name = _appHost.FriendlyName - }; + _logger.LogWarning("Unable to respond to udp request because the local ip address could not be determined."); + return; + } - try - { - await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint).ConfigureAwait(false); - } - catch (SocketException ex) - { - _logger.LogError(ex, "Error sending response message"); - } + var response = new ServerDiscoveryInfo(localUrl, _appHost.SystemId, _appHost.FriendlyName); - var parts = messageText.Split('|'); - if (parts.Length > 1) - { - _appHost.EnableLoopback(parts[1]); - } + try + { + await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint).ConfigureAwait(false); } - else + catch (SocketException ex) { - _logger.LogWarning("Unable to respond to udp request because the local ip address could not be determined."); + _logger.LogError(ex, "Error sending response message"); } } /// <summary> /// Starts the specified port. /// </summary> - /// <param name="port">The port.</param> - /// <param name="cancellationToken"></param> - public void Start(int port, CancellationToken cancellationToken) + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + public void Start(CancellationToken cancellationToken) { - _endpoint = new IPEndPoint(IPAddress.Any, port); - - _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _udpSocket.Bind(_endpoint); _ = Task.Run(async () => await BeginReceiveAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); @@ -99,9 +97,9 @@ namespace Emby.Server.Implementations.Udp private async Task BeginReceiveAsync(CancellationToken cancellationToken) { + var infiniteTask = Task.Delay(-1, cancellationToken); while (!cancellationToken.IsCancellationRequested) { - var infiniteTask = Task.Delay(-1, cancellationToken); try { var task = _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint); diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 4f54c06dd..7b0afa4e2 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -1,24 +1,26 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; -using System.Runtime.Serialization; +using System.Net.Http.Json; using System.Security.Cryptography; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; +using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Events.Updates; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; using Microsoft.Extensions.Logging; @@ -34,19 +36,18 @@ namespace Emby.Server.Implementations.Updates /// </summary> private readonly ILogger<InstallationManager> _logger; private readonly IApplicationPaths _appPaths; - private readonly IHttpClient _httpClient; - private readonly IJsonSerializer _jsonSerializer; + private readonly IEventManager _eventManager; + private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; - private readonly IFileSystem _fileSystem; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly IPluginManager _pluginManager; /// <summary> /// Gets the application host. /// </summary> /// <value>The application host.</value> - private readonly IApplicationHost _applicationHost; - + private readonly IServerApplicationHost _applicationHost; private readonly IZipClient _zipClient; - private readonly object _currentInstallationsLock = new object(); /// <summary> @@ -59,93 +60,103 @@ namespace Emby.Server.Implementations.Updates /// </summary> private readonly ConcurrentBag<InstallationInfo> _completedInstallationsInternal; + /// <summary> + /// Initializes a new instance of the <see cref="InstallationManager"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger{InstallationManager}"/>.</param> + /// <param name="appHost">The <see cref="IServerApplicationHost"/>.</param> + /// <param name="appPaths">The <see cref="IApplicationPaths"/>.</param> + /// <param name="eventManager">The <see cref="IEventManager"/>.</param> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param> + /// <param name="zipClient">The <see cref="IZipClient"/>.</param> + /// <param name="pluginManager">The <see cref="IPluginManager"/>.</param> public InstallationManager( ILogger<InstallationManager> logger, - IApplicationHost appHost, + IServerApplicationHost appHost, IApplicationPaths appPaths, - IHttpClient httpClient, - IJsonSerializer jsonSerializer, + IEventManager eventManager, + IHttpClientFactory httpClientFactory, IServerConfigurationManager config, - IFileSystem fileSystem, - IZipClient zipClient) + IZipClient zipClient, + IPluginManager pluginManager) { - if (logger == null) - { - throw new ArgumentNullException(nameof(logger)); - } - _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>(); _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); _logger = logger; _applicationHost = appHost; _appPaths = appPaths; - _httpClient = httpClient; - _jsonSerializer = jsonSerializer; + _eventManager = eventManager; + _httpClientFactory = httpClientFactory; _config = config; - _fileSystem = fileSystem; _zipClient = zipClient; + _jsonSerializerOptions = JsonDefaults.Options; + _pluginManager = pluginManager; } /// <inheritdoc /> - public event EventHandler<InstallationInfo> PackageInstalling; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PackageInstallationCompleted; - - /// <inheritdoc /> - public event EventHandler<InstallationFailedEventArgs> PackageInstallationFailed; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PackageInstallationCancelled; - - /// <inheritdoc /> - public event EventHandler<IPlugin> PluginUninstalled; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PluginUpdated; - - /// <inheritdoc /> - public event EventHandler<InstallationInfo> PluginInstalled; - - /// <inheritdoc /> public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal; /// <inheritdoc /> - public async Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default) + public async Task<PackageInfo[]> GetPackages(string manifestName, string manifest, bool filterIncompatible, CancellationToken cancellationToken = default) { try { - using (var response = await _httpClient.SendAsync( - new HttpRequestOptions - { - Url = manifest, - CancellationToken = cancellationToken, - CacheMode = CacheMode.Unconditional, - CacheLength = TimeSpan.FromMinutes(3) - }, - HttpMethod.Get).ConfigureAwait(false)) - using (Stream stream = response.Content) + PackageInfo[]? packages = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetFromJsonAsync<PackageInfo[]>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false); + + if (packages == null) { - try - { - return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false); - } - catch (SerializationException ex) + return Array.Empty<PackageInfo>(); + } + + var minimumVersion = new Version(0, 0, 0, 1); + // Store the repository and repository url with each version, as they may be spread apart. + foreach (var entry in packages) + { + for (int a = entry.Versions.Count - 1; a >= 0; a--) { - _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest); - return Array.Empty<PackageInfo>(); + var ver = entry.Versions[a]; + ver.RepositoryName = manifestName; + ver.RepositoryUrl = manifest; + + if (!filterIncompatible) + { + continue; + } + + if (!Version.TryParse(ver.TargetAbi, out var targetAbi)) + { + targetAbi = minimumVersion; + } + + // Only show plugins that are greater than or equal to targetAbi. + if (_applicationHost.ApplicationVersion >= targetAbi) + { + continue; + } + + // Not compatible with this version so remove it. + entry.Versions.Remove(ver); } } + + return packages; } - catch (UriFormatException ex) + catch (IOException ex) { - _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest); + _logger.LogError(ex, "Cannot locate the plugin manifest {Manifest}", manifest); return Array.Empty<PackageInfo>(); } - catch (HttpException ex) + catch (JsonException ex) { - _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest); + _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest); + return Array.Empty<PackageInfo>(); + } + catch (UriFormatException ex) + { + _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest); return Array.Empty<PackageInfo>(); } catch (HttpRequestException ex) @@ -161,7 +172,48 @@ namespace Emby.Server.Implementations.Updates var result = new List<PackageInfo>(); foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories) { - result.AddRange(await GetPackages(repository.Url, cancellationToken).ConfigureAwait(true)); + if (repository.Enabled && repository.Url != null) + { + // Where repositories have the same content, the details from the first is taken. + foreach (var package in await GetPackages(repository.Name ?? "Unnamed Repo", repository.Url, true, cancellationToken).ConfigureAwait(true)) + { + var existing = FilterPackages(result, package.Name, package.Id).FirstOrDefault(); + + // Remove invalid versions from the valid package. + for (var i = package.Versions.Count - 1; i >= 0; i--) + { + var version = package.Versions[i]; + + var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber); + if (plugin != null) + { + await _pluginManager.GenerateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); + } + + // Remove versions with a target ABI greater then the current application version. + if (Version.TryParse(version.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; + } + + if (existing != null) + { + // Assumption is both lists are ordered, so slot these into the correct place. + MergeSortedList(existing.Versions, package.Versions); + } + else + { + result.Add(package); + } + } + } } return result; @@ -170,17 +222,23 @@ namespace Emby.Server.Implementations.Updates /// <inheritdoc /> public IEnumerable<PackageInfo> FilterPackages( IEnumerable<PackageInfo> availablePackages, - string name = null, - Guid guid = default) + string? name = null, + Guid id = default, + Version? specificVersion = null) { if (name != null) { - availablePackages = availablePackages.Where(x => x.name.Equals(name, StringComparison.OrdinalIgnoreCase)); + availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + if (id != default) + { + availablePackages = availablePackages.Where(x => x.Id == id); } - if (guid != Guid.Empty) + if (specificVersion != null) { - availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid); + availablePackages = availablePackages.Where(x => x.Versions.Any(y => y.VersionNumber.Equals(specificVersion))); } return availablePackages; @@ -189,11 +247,12 @@ namespace Emby.Server.Implementations.Updates /// <inheritdoc /> public IEnumerable<InstallationInfo> GetCompatibleVersions( IEnumerable<PackageInfo> availablePackages, - string name = null, - Guid guid = default, - Version minVersion = null) + string? name = null, + Guid id = default, + Version? minVersion = null, + Version? specificVersion = null) { - var package = FilterPackages(availablePackages, name, guid).FirstOrDefault(); + var package = FilterPackages(availablePackages, name, id, specificVersion).FirstOrDefault(); // Package not found in repository if (package == null) @@ -202,24 +261,29 @@ namespace Emby.Server.Implementations.Updates } var appVer = _applicationHost.ApplicationVersion; - var availableVersions = package.versions - .Where(x => Version.Parse(x.targetAbi) <= appVer); + var availableVersions = package.Versions + .Where(x => string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer); - if (minVersion != null) + if (specificVersion != null) { - availableVersions = availableVersions.Where(x => new Version(x.version) >= minVersion); + availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion)); + } + else if (minVersion != null) + { + availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion); } - foreach (var v in availableVersions.OrderByDescending(x => x.version)) + foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber)) { yield return new InstallationInfo { - Changelog = v.changelog, - Guid = new Guid(package.guid), - Name = package.name, - Version = new Version(v.version), - SourceUrl = v.sourceUrl, - Checksum = v.checksum + Changelog = v.Changelog, + Id = package.Id, + Name = package.Name, + Version = v.VersionNumber, + SourceUrl = v.SourceUrl, + Checksum = v.Checksum, + PackageInfo = package }; } } @@ -231,19 +295,6 @@ namespace Emby.Server.Implementations.Updates return GetAvailablePluginUpdates(catalog); } - private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog) - { - foreach (var plugin in _applicationHost.Plugins) - { - var compatibleversions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, plugin.Version); - var version = compatibleversions.FirstOrDefault(y => y.Version > plugin.Version); - if (version != null && CompletedInstallations.All(x => x.Guid != version.Guid)) - { - yield return version; - } - } - } - /// <inheritdoc /> public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken) { @@ -262,13 +313,14 @@ namespace Emby.Server.Implementations.Updates _currentInstallations.Add(tuple); } - var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token; + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token); + var linkedToken = linkedTokenSource.Token; - PackageInstalling?.Invoke(this, package); + await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false); try { - await InstallPackageInternal(package, linkedToken).ConfigureAwait(false); + var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false); lock (_currentInstallationsLock) { @@ -276,8 +328,11 @@ namespace Emby.Server.Implementations.Updates } _completedInstallationsInternal.Add(package); + await _eventManager.PublishAsync(isUpdate + ? (GenericEventArgs<InstallationInfo>)new PluginUpdatedEventArgs(package) + : new PluginInstalledEventArgs(package)).ConfigureAwait(false); - PackageInstallationCompleted?.Invoke(this, package); + _applicationHost.NotifyPendingRestart(); } catch (OperationCanceledException) { @@ -288,7 +343,7 @@ namespace Emby.Server.Implementations.Updates _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version); - PackageInstallationCancelled?.Invoke(this, package); + await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false); throw; } @@ -301,11 +356,11 @@ namespace Emby.Server.Implementations.Updates _currentInstallations.Remove(tuple); } - PackageInstallationFailed?.Invoke(this, new InstallationFailedEventArgs + await _eventManager.PublishAsync(new InstallationFailedEventArgs { InstallationInfo = package, Exception = ex - }); + }).ConfigureAwait(false); throw; } @@ -317,144 +372,28 @@ namespace Emby.Server.Implementations.Updates } /// <summary> - /// Installs the package internal. + /// Uninstalls a plugin. /// </summary> - /// <param name="package">The package.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns><see cref="Task" />.</returns> - private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) + /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param> + public void UninstallPlugin(LocalPlugin plugin) { - // Set last update time if we were installed before - IPlugin plugin = _applicationHost.Plugins.FirstOrDefault(p => p.Id == package.Guid) - ?? _applicationHost.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase)); - - // Do the install - await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false); - - // Do plugin-specific processing if (plugin == null) { - _logger.LogInformation("New plugin installed: {0} {1}", package.Name, package.Version); - - PluginInstalled?.Invoke(this, package); - } - else - { - _logger.LogInformation("Plugin updated: {0} {1}", package.Name, package.Version); - - PluginUpdated?.Invoke(this, package); - } - - _applicationHost.NotifyPendingRestart(); - } - - private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken) - { - var extension = Path.GetExtension(package.SourceUrl); - if (!string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError("Only zip packages are supported. {SourceUrl} is not a zip archive.", package.SourceUrl); return; } - // Always override the passed-in target (which is a file) and figure it out again - string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); - - // CA5351: Do Not Use Broken Cryptographic Algorithms -#pragma warning disable CA5351 - using (var res = await _httpClient.SendAsync( - new HttpRequestOptions - { - Url = package.SourceUrl, - CancellationToken = cancellationToken, - // We need it to be buffered for setting the position - BufferContent = true - }, - HttpMethod.Get).ConfigureAwait(false)) - using (var stream = res.Content) - using (var md5 = MD5.Create()) - { - cancellationToken.ThrowIfCancellationRequested(); - - var hash = Hex.Encode(md5.ComputeHash(stream)); - if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError( - "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", - package.Name, - package.Checksum, - hash); - throw new InvalidDataException("The checksum of the received data doesn't match."); - } - - if (Directory.Exists(targetDir)) - { - Directory.Delete(targetDir, true); - } - - stream.Position = 0; - _zipClient.ExtractAllFromZip(stream, targetDir, true); - } - -#pragma warning restore CA5351 - } - - /// <summary> - /// Uninstalls a plugin. - /// </summary> - /// <param name="plugin">The plugin.</param> - public void UninstallPlugin(IPlugin plugin) - { - if (!plugin.CanUninstall) + if (plugin.Instance?.CanUninstall == false) { - _logger.LogWarning("Attempt to delete non removable plugin {0}, ignoring request", plugin.Name); + _logger.LogWarning("Attempt to delete non removable plugin {PluginName}, ignoring request", plugin.Name); return; } - plugin.OnUninstalling(); + plugin.Instance?.OnUninstalling(); // Remove it the quick way for now - _applicationHost.RemovePlugin(plugin); - - var path = plugin.AssemblyFilePath; - bool isDirectory = false; - // Check if we have a plugin directory we should remove too - if (Path.GetDirectoryName(plugin.AssemblyFilePath) != _appPaths.PluginsPath) - { - path = Path.GetDirectoryName(plugin.AssemblyFilePath); - isDirectory = true; - } + _pluginManager.RemovePlugin(plugin); - // Make this case-insensitive to account for possible incorrect assembly naming - var file = _fileSystem.GetFilePaths(Path.GetDirectoryName(path)) - .FirstOrDefault(i => string.Equals(i, path, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrWhiteSpace(file)) - { - path = file; - } - - if (isDirectory) - { - _logger.LogInformation("Deleting plugin directory {0}", path); - Directory.Delete(path, true); - } - else - { - _logger.LogInformation("Deleting plugin file {0}", path); - _fileSystem.DeleteFile(path); - } - - var list = _config.Configuration.UninstalledPlugins.ToList(); - var filename = Path.GetFileName(path); - if (!list.Contains(filename, StringComparer.OrdinalIgnoreCase)) - { - list.Add(filename); - _config.Configuration.UninstalledPlugins = list.ToArray(); - _config.SaveConfiguration(); - } - - PluginUninstalled?.Invoke(this, plugin); + _eventManager.Publish(new PluginUninstalledEventArgs(plugin.GetPluginInfo())); _applicationHost.NotifyPendingRestart(); } @@ -464,7 +403,7 @@ namespace Emby.Server.Implementations.Updates { lock (_currentInstallationsLock) { - var install = _currentInstallations.Find(x => x.info.Guid == id); + var install = _currentInstallations.Find(x => x.info.Id == id); if (install == default((InstallationInfo, CancellationTokenSource))) { return false; @@ -493,14 +432,148 @@ namespace Emby.Server.Implementations.Updates { lock (_currentInstallationsLock) { - foreach (var tuple in _currentInstallations) + foreach (var (info, token) in _currentInstallations) { - tuple.token.Dispose(); + token.Dispose(); } _currentInstallations.Clear(); } } } + + /// <summary> + /// Merges two sorted lists. + /// </summary> + /// <param name="source">The source <see cref="IList{VersionInfo}"/> instance to merge.</param> + /// <param name="dest">The destination <see cref="IList{VersionInfo}"/> instance to merge with.</param> + private static void MergeSortedList(IList<VersionInfo> source, IList<VersionInfo> dest) + { + int sLength = source.Count - 1; + int dLength = dest.Count; + int s = 0, d = 0; + var sourceVersion = source[0].VersionNumber; + var destVersion = dest[0].VersionNumber; + + while (d < dLength) + { + if (sourceVersion.CompareTo(destVersion) >= 0) + { + if (s < sLength) + { + sourceVersion = source[++s].VersionNumber; + } + else + { + // Append all of destination to the end of source. + while (d < dLength) + { + source.Add(dest[d++]); + } + + break; + } + } + else + { + source.Insert(s++, dest[d++]); + if (d >= dLength) + { + break; + } + + sLength++; + destVersion = dest[d].VersionNumber; + } + } + } + + private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog) + { + var plugins = _pluginManager.Plugins; + foreach (var plugin in plugins) + { + // Don't auto update when plugin marked not to, or when it's disabled. + if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled) + { + continue; + } + + var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version); + var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version); + + if (version != null && CompletedInstallations.All(x => x.Id != version.Id)) + { + yield return version; + } + } + } + + private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken cancellationToken) + { + var extension = Path.GetExtension(package.SourceUrl); + if (!string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError("Only zip packages are supported. {SourceUrl} is not a zip archive.", package.SourceUrl); + return; + } + + // Always override the passed-in target (which is a file) and figure it out again + string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); + + 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 +#pragma warning disable CA5351 + using var md5 = MD5.Create(); + cancellationToken.ThrowIfCancellationRequested(); + + var hash = Convert.ToHexString(md5.ComputeHash(stream)); + if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "The checksums didn't match while installing {Package}, expected: {Expected}, got: {Received}", + package.Name, + package.Checksum, + hash); + throw new InvalidDataException("The checksum of the received data doesn't match."); + } + + // Version folder as they cannot be overwritten in Windows. + targetDir += "_" + package.Version; + + if (Directory.Exists(targetDir)) + { + try + { + Directory.Delete(targetDir, true); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + // Ignore any exceptions. + } + } + + stream.Position = 0; + _zipClient.ExtractAllFromZip(stream, targetDir, true); + await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); + _pluginManager.ImportPluginFrom(targetDir); + } + + private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) + { + LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version)) + ?? _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(package.Name, StringComparison.OrdinalIgnoreCase) && p.Version.Equals(package.Version)); + + await PerformPackageInstallation(package, plugin?.Manifest.Status ?? PluginStatus.Active, cancellationToken).ConfigureAwait(false); + _logger.LogInformation(plugin == null ? "New plugin installed: {PluginName} {PluginVersion}" : "Plugin updated: {PluginName} {PluginVersion}", package.Name, package.Version); + + return plugin != null; + } } } |
