diff options
| author | WWWesten <4700006+WWWesten@users.noreply.github.com> | 2021-11-01 23:43:29 +0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-11-01 23:43:29 +0500 |
| commit | 0a14279e2a21bcb9654a06a2d49e1e4f0cc5329c (patch) | |
| tree | e1b1bd603b011ca98e5793e356326bf4a35a7050 /Emby.Server.Implementations | |
| parent | f2817fef743eeb75a00782ceea363b2d3e7dc9f2 (diff) | |
| parent | 76eeb8f655424d295e73ced8349c6fefee6ddb12 (diff) | |
Merge branch 'jellyfin:master' into master
Diffstat (limited to 'Emby.Server.Implementations')
354 files changed, 22855 insertions, 30207 deletions
diff --git a/Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs b/Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs deleted file mode 100644 index 739f68767..000000000 --- a/Emby.Server.Implementations/Activity/ActivityLogEntryPoint.cs +++ /dev/null @@ -1,577 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Common.Updates; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.Subtitles; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Notifications; -using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Updates; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Activity -{ - public class ActivityLogEntryPoint : IServerEntryPoint - { - private readonly IInstallationManager _installationManager; - - //private readonly ILogger _logger; - private readonly ISessionManager _sessionManager; - private readonly ITaskManager _taskManager; - private readonly IActivityManager _activityManager; - private readonly ILocalizationManager _localization; - - private readonly ILibraryManager _libraryManager; - private readonly ISubtitleManager _subManager; - private readonly IUserManager _userManager; - private readonly IServerConfigurationManager _config; - private readonly IServerApplicationHost _appHost; - private readonly IDeviceManager _deviceManager; - - public ActivityLogEntryPoint(ISessionManager sessionManager, IDeviceManager deviceManager, ITaskManager taskManager, IActivityManager activityManager, ILocalizationManager localization, IInstallationManager installationManager, ILibraryManager libraryManager, ISubtitleManager subManager, IUserManager userManager, IServerConfigurationManager config, IServerApplicationHost appHost) - { - _sessionManager = sessionManager; - _taskManager = taskManager; - _activityManager = activityManager; - _localization = localization; - _installationManager = installationManager; - _libraryManager = libraryManager; - _subManager = subManager; - _userManager = userManager; - _config = config; - _appHost = appHost; - _deviceManager = deviceManager; - } - - public Task RunAsync() - { - _taskManager.TaskCompleted += _taskManager_TaskCompleted; - - _installationManager.PluginInstalled += _installationManager_PluginInstalled; - _installationManager.PluginUninstalled += _installationManager_PluginUninstalled; - _installationManager.PluginUpdated += _installationManager_PluginUpdated; - _installationManager.PackageInstallationFailed += _installationManager_PackageInstallationFailed; - - _sessionManager.SessionStarted += _sessionManager_SessionStarted; - _sessionManager.AuthenticationFailed += _sessionManager_AuthenticationFailed; - _sessionManager.AuthenticationSucceeded += _sessionManager_AuthenticationSucceeded; - _sessionManager.SessionEnded += _sessionManager_SessionEnded; - - _sessionManager.PlaybackStart += _sessionManager_PlaybackStart; - _sessionManager.PlaybackStopped += _sessionManager_PlaybackStopped; - - //_subManager.SubtitlesDownloaded += _subManager_SubtitlesDownloaded; - _subManager.SubtitleDownloadFailure += _subManager_SubtitleDownloadFailure; - - _userManager.UserCreated += _userManager_UserCreated; - _userManager.UserPasswordChanged += _userManager_UserPasswordChanged; - _userManager.UserDeleted += _userManager_UserDeleted; - _userManager.UserPolicyUpdated += _userManager_UserPolicyUpdated; - _userManager.UserLockedOut += _userManager_UserLockedOut; - - //_config.ConfigurationUpdated += _config_ConfigurationUpdated; - //_config.NamedConfigurationUpdated += _config_NamedConfigurationUpdated; - - _deviceManager.CameraImageUploaded += _deviceManager_CameraImageUploaded; - - _appHost.ApplicationUpdated += _appHost_ApplicationUpdated; - - return Task.CompletedTask; - } - - void _deviceManager_CameraImageUploaded(object sender, GenericEventArgs<CameraImageUploadInfo> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("CameraImageUploadedFrom"), e.Argument.Device.Name), - Type = NotificationType.CameraImageUploaded.ToString() - }); - } - - void _userManager_UserLockedOut(object sender, GenericEventArgs<User> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("UserLockedOutWithName"), e.Argument.Name), - Type = NotificationType.UserLockedOut.ToString(), - UserId = e.Argument.Id - }); - } - - void _subManager_SubtitleDownloadFailure(object sender, SubtitleDownloadFailureEventArgs e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("SubtitleDownloadFailureFromForItem"), e.Provider, Notifications.Notifications.GetItemName(e.Item)), - Type = "SubtitleDownloadFailure", - ItemId = e.Item.Id.ToString("N"), - ShortOverview = e.Exception.Message - }); - } - - void _sessionManager_PlaybackStopped(object sender, PlaybackStopEventArgs e) - { - var item = e.MediaInfo; - - if (item == null) - { - //_logger.LogWarning("PlaybackStopped reported with null media info."); - return; - } - - if (e.Item != null && e.Item.IsThemeMedia) - { - // Don't report theme song or local trailer playback - return; - } - - if (e.Users.Count == 0) - { - return; - } - - var user = e.Users.First(); - - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("UserStoppedPlayingItemWithValues"), user.Name, GetItemName(item), e.DeviceName), - Type = GetPlaybackStoppedNotificationType(item.MediaType), - UserId = user.Id - }); - } - - void _sessionManager_PlaybackStart(object sender, PlaybackProgressEventArgs e) - { - var item = e.MediaInfo; - - if (item == null) - { - //_logger.LogWarning("PlaybackStart reported with null media info."); - return; - } - - if (e.Item != null && e.Item.IsThemeMedia) - { - // Don't report theme song or local trailer playback - return; - } - - if (e.Users.Count == 0) - { - return; - } - - var user = e.Users.First(); - - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("UserStartedPlayingItemWithValues"), user.Name, GetItemName(item), e.DeviceName), - Type = GetPlaybackNotificationType(item.MediaType), - UserId = user.Id - }); - } - - private static string GetItemName(BaseItemDto item) - { - var name = item.Name; - - if (!string.IsNullOrEmpty(item.SeriesName)) - { - name = item.SeriesName + " - " + name; - } - - if (item.Artists != null && item.Artists.Length > 0) - { - name = item.Artists[0] + " - " + name; - } - - return name; - } - - private static string GetPlaybackNotificationType(string mediaType) - { - if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) - { - return NotificationType.AudioPlayback.ToString(); - } - if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) - { - return NotificationType.VideoPlayback.ToString(); - } - - return null; - } - - private static string GetPlaybackStoppedNotificationType(string mediaType) - { - if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) - { - return NotificationType.AudioPlaybackStopped.ToString(); - } - if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) - { - return NotificationType.VideoPlaybackStopped.ToString(); - } - - return null; - } - - void _sessionManager_SessionEnded(object sender, SessionEventArgs e) - { - string name; - var session = e.SessionInfo; - - if (string.IsNullOrEmpty(session.UserName)) - { - name = string.Format(_localization.GetLocalizedString("DeviceOfflineWithName"), session.DeviceName); - - // Causing too much spam for now - return; - } - else - { - name = string.Format(_localization.GetLocalizedString("UserOfflineFromDevice"), session.UserName, session.DeviceName); - } - - CreateLogEntry(new ActivityLogEntry - { - Name = name, - Type = "SessionEnded", - ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), session.RemoteEndPoint), - UserId = session.UserId - }); - } - - void _sessionManager_AuthenticationSucceeded(object sender, GenericEventArgs<AuthenticationResult> e) - { - var user = e.Argument.User; - - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("AuthenticationSucceededWithUserName"), user.Name), - Type = "AuthenticationSucceeded", - ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), e.Argument.SessionInfo.RemoteEndPoint), - UserId = user.Id - }); - } - - void _sessionManager_AuthenticationFailed(object sender, GenericEventArgs<AuthenticationRequest> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("FailedLoginAttemptWithUserName"), e.Argument.Username), - Type = "AuthenticationFailed", - ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), e.Argument.RemoteEndPoint), - Severity = LogLevel.Error - }); - } - - void _appHost_ApplicationUpdated(object sender, GenericEventArgs<PackageVersionInfo> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("MessageApplicationUpdatedTo"), e.Argument.versionStr), - Type = NotificationType.ApplicationUpdateInstalled.ToString(), - Overview = e.Argument.description - }); - } - - void _config_NamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("MessageNamedServerConfigurationUpdatedWithValue"), e.Key), - Type = "NamedConfigurationUpdated" - }); - } - - void _config_ConfigurationUpdated(object sender, EventArgs e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = _localization.GetLocalizedString("MessageServerConfigurationUpdated"), - Type = "ServerConfigurationUpdated" - }); - } - - void _userManager_UserPolicyUpdated(object sender, GenericEventArgs<User> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("UserPolicyUpdatedWithName"), e.Argument.Name), - Type = "UserPolicyUpdated", - UserId = e.Argument.Id - }); - } - - void _userManager_UserDeleted(object sender, GenericEventArgs<User> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("UserDeletedWithName"), e.Argument.Name), - Type = "UserDeleted" - }); - } - - void _userManager_UserPasswordChanged(object sender, GenericEventArgs<User> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("UserPasswordChangedWithName"), e.Argument.Name), - Type = "UserPasswordChanged", - UserId = e.Argument.Id - }); - } - - void _userManager_UserCreated(object sender, GenericEventArgs<User> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("UserCreatedWithName"), e.Argument.Name), - Type = "UserCreated", - UserId = e.Argument.Id - }); - } - - void _subManager_SubtitlesDownloaded(object sender, SubtitleDownloadEventArgs e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("SubtitlesDownloadedForItem"), Notifications.Notifications.GetItemName(e.Item)), - Type = "SubtitlesDownloaded", - ItemId = e.Item.Id.ToString("N"), - ShortOverview = string.Format(_localization.GetLocalizedString("ProviderValue"), e.Provider) - }); - } - - void _sessionManager_SessionStarted(object sender, SessionEventArgs e) - { - string name; - var session = e.SessionInfo; - - if (string.IsNullOrEmpty(session.UserName)) - { - name = string.Format(_localization.GetLocalizedString("DeviceOnlineWithName"), session.DeviceName); - - // Causing too much spam for now - return; - } - else - { - name = string.Format(_localization.GetLocalizedString("UserOnlineFromDevice"), session.UserName, session.DeviceName); - } - - CreateLogEntry(new ActivityLogEntry - { - Name = name, - Type = "SessionStarted", - ShortOverview = string.Format(_localization.GetLocalizedString("LabelIpAddressValue"), session.RemoteEndPoint), - UserId = session.UserId - }); - } - - void _installationManager_PluginUpdated(object sender, GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("PluginUpdatedWithName"), e.Argument.Item1.Name), - Type = NotificationType.PluginUpdateInstalled.ToString(), - ShortOverview = string.Format(_localization.GetLocalizedString("VersionNumber"), e.Argument.Item2.versionStr), - Overview = e.Argument.Item2.description - }); - } - - void _installationManager_PluginUninstalled(object sender, GenericEventArgs<IPlugin> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("PluginUninstalledWithName"), e.Argument.Name), - Type = NotificationType.PluginUninstalled.ToString() - }); - } - - void _installationManager_PluginInstalled(object sender, GenericEventArgs<PackageVersionInfo> e) - { - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("PluginInstalledWithName"), e.Argument.name), - Type = NotificationType.PluginInstalled.ToString(), - ShortOverview = string.Format(_localization.GetLocalizedString("VersionNumber"), e.Argument.versionStr) - }); - } - - void _installationManager_PackageInstallationFailed(object sender, InstallationFailedEventArgs e) - { - var installationInfo = e.InstallationInfo; - - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("NameInstallFailed"), installationInfo.Name), - Type = NotificationType.InstallationFailed.ToString(), - ShortOverview = string.Format(_localization.GetLocalizedString("VersionNumber"), installationInfo.Version), - Overview = e.Exception.Message - }); - } - - void _taskManager_TaskCompleted(object sender, TaskCompletionEventArgs e) - { - var result = e.Result; - var task = e.Task; - - var activityTask = task.ScheduledTask as IConfigurableScheduledTask; - if (activityTask != null && !activityTask.IsLogged) - { - return; - } - - var time = result.EndTimeUtc - result.StartTimeUtc; - var runningTime = string.Format(_localization.GetLocalizedString("LabelRunningTimeValue"), ToUserFriendlyString(time)); - - if (result.Status == TaskCompletionStatus.Failed) - { - var vals = new List<string>(); - - if (!string.IsNullOrEmpty(e.Result.ErrorMessage)) - { - vals.Add(e.Result.ErrorMessage); - } - if (!string.IsNullOrEmpty(e.Result.LongErrorMessage)) - { - vals.Add(e.Result.LongErrorMessage); - } - - CreateLogEntry(new ActivityLogEntry - { - Name = string.Format(_localization.GetLocalizedString("ScheduledTaskFailedWithName"), task.Name), - Type = NotificationType.TaskFailed.ToString(), - Overview = string.Join(Environment.NewLine, vals.ToArray()), - ShortOverview = runningTime, - Severity = LogLevel.Error - }); - } - } - - private void CreateLogEntry(ActivityLogEntry entry) - { - try - { - _activityManager.Create(entry); - } - catch - { - // Logged at lower levels - } - } - - public void Dispose() - { - _taskManager.TaskCompleted -= _taskManager_TaskCompleted; - - _installationManager.PluginInstalled -= _installationManager_PluginInstalled; - _installationManager.PluginUninstalled -= _installationManager_PluginUninstalled; - _installationManager.PluginUpdated -= _installationManager_PluginUpdated; - _installationManager.PackageInstallationFailed -= _installationManager_PackageInstallationFailed; - - _sessionManager.SessionStarted -= _sessionManager_SessionStarted; - _sessionManager.AuthenticationFailed -= _sessionManager_AuthenticationFailed; - _sessionManager.AuthenticationSucceeded -= _sessionManager_AuthenticationSucceeded; - _sessionManager.SessionEnded -= _sessionManager_SessionEnded; - - _sessionManager.PlaybackStart -= _sessionManager_PlaybackStart; - _sessionManager.PlaybackStopped -= _sessionManager_PlaybackStopped; - - _subManager.SubtitleDownloadFailure -= _subManager_SubtitleDownloadFailure; - - _userManager.UserCreated -= _userManager_UserCreated; - _userManager.UserPasswordChanged -= _userManager_UserPasswordChanged; - _userManager.UserDeleted -= _userManager_UserDeleted; - _userManager.UserPolicyUpdated -= _userManager_UserPolicyUpdated; - _userManager.UserLockedOut -= _userManager_UserLockedOut; - - _config.ConfigurationUpdated -= _config_ConfigurationUpdated; - _config.NamedConfigurationUpdated -= _config_NamedConfigurationUpdated; - - _deviceManager.CameraImageUploaded -= _deviceManager_CameraImageUploaded; - - _appHost.ApplicationUpdated -= _appHost_ApplicationUpdated; - } - - /// <summary> - /// Constructs a user-friendly string for this TimeSpan instance. - /// </summary> - public static string ToUserFriendlyString(TimeSpan span) - { - const int DaysInYear = 365; - const int DaysInMonth = 30; - - // Get each non-zero value from TimeSpan component - var values = new List<string>(); - - // Number of years - int days = span.Days; - if (days >= DaysInYear) - { - int years = days / DaysInYear; - values.Add(CreateValueString(years, "year")); - days = days % DaysInYear; - } - // Number of months - if (days >= DaysInMonth) - { - int months = days / DaysInMonth; - values.Add(CreateValueString(months, "month")); - days = days % DaysInMonth; - } - // Number of days - if (days >= 1) - values.Add(CreateValueString(days, "day")); - // Number of hours - if (span.Hours >= 1) - values.Add(CreateValueString(span.Hours, "hour")); - // Number of minutes - if (span.Minutes >= 1) - values.Add(CreateValueString(span.Minutes, "minute")); - // Number of seconds (include when 0 if no other components included) - if (span.Seconds >= 1 || values.Count == 0) - values.Add(CreateValueString(span.Seconds, "second")); - - // Combine values into string - var builder = new StringBuilder(); - for (int i = 0; i < values.Count; i++) - { - if (builder.Length > 0) - builder.Append(i == values.Count - 1 ? " and " : ", "); - builder.Append(values[i]); - } - // Return result - return builder.ToString(); - } - - /// <summary> - /// Constructs a string description of a time-span value. - /// </summary> - /// <param name="value">The value of this item</param> - /// <param name="description">The name of this item (singular form)</param> - private static string CreateValueString(int value, string description) - { - return string.Format("{0:#,##0} {1}", - value, value == 1 ? description : string.Format("{0}s", description)); - } - } -} diff --git a/Emby.Server.Implementations/Activity/ActivityManager.cs b/Emby.Server.Implementations/Activity/ActivityManager.cs deleted file mode 100644 index 6febcc2f7..000000000 --- a/Emby.Server.Implementations/Activity/ActivityManager.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Linq; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Querying; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Activity -{ - public class ActivityManager : IActivityManager - { - public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated; - - private readonly IActivityRepository _repo; - private readonly ILogger _logger; - private readonly IUserManager _userManager; - - public ActivityManager( - ILoggerFactory loggerFactory, - IActivityRepository repo, - IUserManager userManager) - { - _logger = loggerFactory.CreateLogger(nameof(ActivityManager)); - _repo = repo; - _userManager = userManager; - } - - public void Create(ActivityLogEntry entry) - { - entry.Date = DateTime.UtcNow; - - _repo.Create(entry); - - EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(entry)); - } - - public QueryResult<ActivityLogEntry> GetActivityLogEntries(DateTime? minDate, bool? hasUserId, int? startIndex, int? limit) - { - var result = _repo.GetActivityLogEntries(minDate, hasUserId, startIndex, limit); - - foreach (var item in result.Items.Where(i => !i.UserId.Equals(Guid.Empty))) - { - var user = _userManager.GetUserById(item.UserId); - - if (user != null) - { - var dto = _userManager.GetUserDto(user); - item.UserPrimaryImageTag = dto.PrimaryImageTag; - } - } - - return result; - } - - public QueryResult<ActivityLogEntry> GetActivityLogEntries(DateTime? minDate, int? startIndex, int? limit) - { - return GetActivityLogEntries(minDate, null, startIndex, limit); - } - } -} diff --git a/Emby.Server.Implementations/Activity/ActivityRepository.cs b/Emby.Server.Implementations/Activity/ActivityRepository.cs deleted file mode 100644 index aeed8b6f1..000000000 --- a/Emby.Server.Implementations/Activity/ActivityRepository.cs +++ /dev/null @@ -1,310 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using Emby.Server.Implementations.Data; -using MediaBrowser.Controller; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; -using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Activity -{ - public class ActivityRepository : BaseSqliteRepository, IActivityRepository - { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - protected IFileSystem FileSystem { get; private set; } - - public ActivityRepository(ILoggerFactory loggerFactory, IServerApplicationPaths appPaths, IFileSystem fileSystem) - : base(loggerFactory.CreateLogger(nameof(ActivityRepository))) - { - DbFilePath = Path.Combine(appPaths.DataPath, "activitylog.db"); - FileSystem = fileSystem; - } - - public void Initialize() - { - try - { - InitializeInternal(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error loading database file. Will reset and retry."); - - FileSystem.DeleteFile(DbFilePath); - - InitializeInternal(); - } - } - - private void InitializeInternal() - { - using (var connection = CreateConnection()) - { - RunDefaultInitialization(connection); - - connection.RunQueries(new[] - { - "create table if not exists ActivityLog (Id INTEGER PRIMARY KEY, Name TEXT NOT NULL, Overview TEXT, ShortOverview TEXT, Type TEXT NOT NULL, ItemId TEXT, UserId TEXT, DateCreated DATETIME NOT NULL, LogSeverity TEXT NOT NULL)", - "drop index if exists idx_ActivityLogEntries" - }); - - TryMigrate(connection); - } - } - - private void TryMigrate(ManagedConnection connection) - { - try - { - if (TableExists(connection, "ActivityLogEntries")) - { - connection.RunQueries(new[] - { - "INSERT INTO ActivityLog (Name, Overview, ShortOverview, Type, ItemId, UserId, DateCreated, LogSeverity) SELECT Name, Overview, ShortOverview, Type, ItemId, UserId, DateCreated, LogSeverity FROM ActivityLogEntries", - "drop table if exists ActivityLogEntries" - }); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error migrating activity log database"); - } - } - - private const string BaseActivitySelectText = "select Id, Name, Overview, ShortOverview, Type, ItemId, UserId, DateCreated, LogSeverity from ActivityLog"; - - public void Create(ActivityLogEntry entry) - { - if (entry == null) - { - throw new ArgumentNullException(nameof(entry)); - } - - using (WriteLock.Write()) - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - using (var statement = db.PrepareStatement("insert into ActivityLog (Name, Overview, ShortOverview, Type, ItemId, UserId, DateCreated, LogSeverity) values (@Name, @Overview, @ShortOverview, @Type, @ItemId, @UserId, @DateCreated, @LogSeverity)")) - { - statement.TryBind("@Name", entry.Name); - - statement.TryBind("@Overview", entry.Overview); - statement.TryBind("@ShortOverview", entry.ShortOverview); - statement.TryBind("@Type", entry.Type); - statement.TryBind("@ItemId", entry.ItemId); - - if (entry.UserId.Equals(Guid.Empty)) - { - statement.TryBindNull("@UserId"); - } - else - { - statement.TryBind("@UserId", entry.UserId.ToString("N")); - } - - statement.TryBind("@DateCreated", entry.Date.ToDateTimeParamValue()); - statement.TryBind("@LogSeverity", entry.Severity.ToString()); - - statement.MoveNext(); - } - }, TransactionMode); - } - } - - public void Update(ActivityLogEntry entry) - { - if (entry == null) - { - throw new ArgumentNullException(nameof(entry)); - } - - using (WriteLock.Write()) - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - using (var statement = db.PrepareStatement("Update ActivityLog set Name=@Name,Overview=@Overview,ShortOverview=@ShortOverview,Type=@Type,ItemId=@ItemId,UserId=@UserId,DateCreated=@DateCreated,LogSeverity=@LogSeverity where Id=@Id")) - { - statement.TryBind("@Id", entry.Id); - - statement.TryBind("@Name", entry.Name); - statement.TryBind("@Overview", entry.Overview); - statement.TryBind("@ShortOverview", entry.ShortOverview); - statement.TryBind("@Type", entry.Type); - statement.TryBind("@ItemId", entry.ItemId); - - if (entry.UserId.Equals(Guid.Empty)) - { - statement.TryBindNull("@UserId"); - } - else - { - statement.TryBind("@UserId", entry.UserId.ToString("N")); - } - - statement.TryBind("@DateCreated", entry.Date.ToDateTimeParamValue()); - statement.TryBind("@LogSeverity", entry.Severity.ToString()); - - statement.MoveNext(); - } - }, TransactionMode); - } - } - - public QueryResult<ActivityLogEntry> GetActivityLogEntries(DateTime? minDate, bool? hasUserId, int? startIndex, int? limit) - { - using (WriteLock.Read()) - using (var connection = CreateConnection(true)) - { - var commandText = BaseActivitySelectText; - var whereClauses = new List<string>(); - - if (minDate.HasValue) - { - whereClauses.Add("DateCreated>=@DateCreated"); - } - if (hasUserId.HasValue) - { - if (hasUserId.Value) - { - whereClauses.Add("UserId not null"); - } - else - { - whereClauses.Add("UserId is null"); - } - } - - var whereTextWithoutPaging = whereClauses.Count == 0 ? - string.Empty : - " where " + string.Join(" AND ", whereClauses.ToArray()); - - if (startIndex.HasValue && startIndex.Value > 0) - { - var pagingWhereText = whereClauses.Count == 0 ? - string.Empty : - " where " + string.Join(" AND ", whereClauses.ToArray()); - - whereClauses.Add(string.Format("Id NOT IN (SELECT Id FROM ActivityLog {0} ORDER BY DateCreated DESC LIMIT {1})", - pagingWhereText, - startIndex.Value.ToString(_usCulture))); - } - - var whereText = whereClauses.Count == 0 ? - string.Empty : - " where " + string.Join(" AND ", whereClauses.ToArray()); - - commandText += whereText; - - commandText += " ORDER BY DateCreated DESC"; - - if (limit.HasValue) - { - commandText += " LIMIT " + limit.Value.ToString(_usCulture); - } - - var statementTexts = new List<string>(); - statementTexts.Add(commandText); - statementTexts.Add("select count (Id) from ActivityLog" + whereTextWithoutPaging); - - return connection.RunInTransaction(db => - { - var list = new List<ActivityLogEntry>(); - var result = new QueryResult<ActivityLogEntry>(); - - var statements = PrepareAllSafe(db, statementTexts).ToList(); - - using (var statement = statements[0]) - { - if (minDate.HasValue) - { - statement.TryBind("@DateCreated", minDate.Value.ToDateTimeParamValue()); - } - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetEntry(row)); - } - } - - using (var statement = statements[1]) - { - if (minDate.HasValue) - { - statement.TryBind("@DateCreated", minDate.Value.ToDateTimeParamValue()); - } - - result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); - } - - result.Items = list.ToArray(); - return result; - - }, ReadTransactionMode); - } - } - - private static ActivityLogEntry GetEntry(IReadOnlyList<IResultSetValue> reader) - { - var index = 0; - - var info = new ActivityLogEntry - { - Id = reader[index].ToInt64() - }; - - index++; - if (reader[index].SQLiteType != SQLiteType.Null) - { - info.Name = reader[index].ToString(); - } - - index++; - if (reader[index].SQLiteType != SQLiteType.Null) - { - info.Overview = reader[index].ToString(); - } - - index++; - if (reader[index].SQLiteType != SQLiteType.Null) - { - info.ShortOverview = reader[index].ToString(); - } - - index++; - if (reader[index].SQLiteType != SQLiteType.Null) - { - info.Type = reader[index].ToString(); - } - - index++; - if (reader[index].SQLiteType != SQLiteType.Null) - { - info.ItemId = reader[index].ToString(); - } - - index++; - if (reader[index].SQLiteType != SQLiteType.Null) - { - info.UserId = new Guid(reader[index].ToString()); - } - - index++; - info.Date = reader[index].ReadDateTime(); - - index++; - if (reader[index].SQLiteType != SQLiteType.Null) - { - info.Severity = (LogLevel)Enum.Parse(typeof(LogLevel), reader[index].ToString(), true); - } - - return info; - } - } -} diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 65cdccfa5..6edfad575 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -5,54 +5,60 @@ using MediaBrowser.Common.Configuration; namespace Emby.Server.Implementations.AppBase { /// <summary> - /// Provides a base class to hold common application paths used by both the Ui and Server. + /// Provides a base class to hold common application paths used by both the UI and Server. /// This can be subclassed to add application-specific paths. /// </summary> public abstract class BaseApplicationPaths : IApplicationPaths { + private string _dataPath; + /// <summary> /// Initializes a new instance of the <see cref="BaseApplicationPaths"/> class. /// </summary> + /// <param name="programDataPath">The program data path.</param> + /// <param name="logDirectoryPath">The log directory path.</param> + /// <param name="configurationDirectoryPath">The configuration directory path.</param> + /// <param name="cacheDirectoryPath">The cache directory path.</param> + /// <param name="webDirectoryPath">The web directory path.</param> protected BaseApplicationPaths( string programDataPath, string logDirectoryPath, string configurationDirectoryPath, - string cacheDirectoryPath) + string cacheDirectoryPath, + string webDirectoryPath) { ProgramDataPath = programDataPath; LogDirectoryPath = logDirectoryPath; ConfigurationDirectoryPath = configurationDirectoryPath; CachePath = cacheDirectoryPath; + WebPath = webDirectoryPath; - DataPath = Path.Combine(ProgramDataPath, "data"); + _dataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } /// <summary> - /// Gets the path to the program data folder + /// Gets the path to the program data folder. /// </summary> /// <value>The program data path.</value> - public string ProgramDataPath { get; private set; } + public string ProgramDataPath { get; } + + /// <inheritdoc/> + public string WebPath { get; } /// <summary> - /// Gets the path to the system folder + /// Gets the path to the system folder. /// </summary> + /// <value>The path to the system folder.</value> public string ProgramSystemPath { get; } = AppContext.BaseDirectory; /// <summary> - /// Gets the folder path to the data directory + /// Gets the folder path to the data directory. /// </summary> /// <value>The data directory.</value> - private string _dataPath; - public string DataPath - { - get => _dataPath; - private set => _dataPath = Directory.CreateDirectory(value).FullName; - } + public string DataPath => _dataPath; - /// <summary> - /// Gets the magic strings used for virtual path manipulation. - /// </summary> - public string VirtualDataPath { get; } = "%AppDataPath%"; + /// <inheritdoc /> + public string VirtualDataPath => "%AppDataPath%"; /// <summary> /// Gets the image cache path. @@ -61,43 +67,43 @@ namespace Emby.Server.Implementations.AppBase public string ImageCachePath => Path.Combine(CachePath, "images"); /// <summary> - /// Gets the path to the plugin directory + /// Gets the path to the plugin directory. /// </summary> /// <value>The plugins path.</value> public string PluginsPath => Path.Combine(ProgramDataPath, "plugins"); /// <summary> - /// Gets the path to the plugin configurations directory + /// Gets the path to the plugin configurations directory. /// </summary> /// <value>The plugin configurations path.</value> public string PluginConfigurationsPath => Path.Combine(PluginsPath, "configurations"); /// <summary> - /// Gets the path to the log directory + /// Gets the path to the log directory. /// </summary> /// <value>The log directory path.</value> - public string LogDirectoryPath { get; private set; } + public string LogDirectoryPath { get; } /// <summary> - /// Gets the path to the application configuration root directory + /// Gets the path to the application configuration root directory. /// </summary> /// <value>The configuration directory path.</value> - public string ConfigurationDirectoryPath { get; private set; } + public string ConfigurationDirectoryPath { get; } /// <summary> - /// Gets the path to the system configuration file + /// Gets the path to the system configuration file. /// </summary> /// <value>The system configuration file path.</value> public string SystemConfigurationFilePath => Path.Combine(ConfigurationDirectoryPath, "system.xml"); /// <summary> - /// Gets the folder path to the cache directory + /// Gets or sets the folder path to the cache directory. /// </summary> /// <value>The cache directory.</value> public string CachePath { get; set; } /// <summary> - /// Gets the folder path to the temp directory within the cache folder + /// Gets the folder path to the temp directory within the cache folder. /// </summary> /// <value>The temp directory.</value> public string TempDirectory => Path.Combine(CachePath, "temp"); diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index af60a8dce..d38535634 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -1,9 +1,11 @@ +#nullable disable + using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; -using System.Threading; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; @@ -15,15 +17,48 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.AppBase { /// <summary> - /// Class BaseConfigurationManager + /// Class BaseConfigurationManager. /// </summary> public abstract class BaseConfigurationManager : IConfigurationManager { + private readonly IFileSystem _fileSystem; + + private readonly ConcurrentDictionary<string, object> _configurations = new ConcurrentDictionary<string, object>(); + /// <summary> - /// Gets the type of the configuration. + /// The _configuration sync lock. /// </summary> - /// <value>The type of the configuration.</value> - protected abstract Type ConfigurationType { get; } + private readonly object _configurationSyncLock = new object(); + + private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>(); + private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>(); + + /// <summary> + /// The _configuration loaded. + /// </summary> + private bool _configurationLoaded; + + /// <summary> + /// The _configuration. + /// </summary> + private BaseApplicationConfiguration _configuration; + + /// <summary> + /// Initializes a new instance of the <see cref="BaseConfigurationManager" /> class. + /// </summary> + /// <param name="applicationPaths">The application paths.</param> + /// <param name="loggerFactory">The logger factory.</param> + /// <param name="xmlSerializer">The XML serializer.</param> + /// <param name="fileSystem">The file system.</param> + protected BaseConfigurationManager(IApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IXmlSerializer xmlSerializer, IFileSystem fileSystem) + { + CommonApplicationPaths = applicationPaths; + XmlSerializer = xmlSerializer; + _fileSystem = fileSystem; + Logger = loggerFactory.CreateLogger<BaseConfigurationManager>(); + + UpdateCachePath(); + } /// <summary> /// Occurs when [configuration updated]. @@ -41,10 +76,17 @@ namespace Emby.Server.Implementations.AppBase public event EventHandler<ConfigurationUpdateEventArgs> NamedConfigurationUpdated; /// <summary> + /// Gets the type of the configuration. + /// </summary> + /// <value>The type of the configuration.</value> + protected abstract Type ConfigurationType { get; } + + /// <summary> /// Gets the logger. /// </summary> /// <value>The logger.</value> - protected ILogger Logger { get; private set; } + protected ILogger<BaseConfigurationManager> Logger { get; private set; } + /// <summary> /// Gets the XML serializer. /// </summary> @@ -52,36 +94,39 @@ namespace Emby.Server.Implementations.AppBase protected IXmlSerializer XmlSerializer { get; private set; } /// <summary> - /// Gets or sets the application paths. + /// Gets the application paths. /// </summary> /// <value>The application paths.</value> public IApplicationPaths CommonApplicationPaths { get; private set; } - public readonly IFileSystem FileSystem; /// <summary> - /// The _configuration loaded - /// </summary> - private bool _configurationLoaded; - /// <summary> - /// The _configuration sync lock - /// </summary> - private object _configurationSyncLock = new object(); - /// <summary> - /// The _configuration - /// </summary> - private BaseApplicationConfiguration _configuration; - /// <summary> - /// Gets the system configuration + /// Gets or sets the system configuration. /// </summary> /// <value>The configuration.</value> public BaseApplicationConfiguration CommonConfiguration { get { - // Lazy load - LazyInitializer.EnsureInitialized(ref _configuration, ref _configurationLoaded, ref _configurationSyncLock, () => (BaseApplicationConfiguration)ConfigurationHelper.GetXmlConfiguration(ConfigurationType, CommonApplicationPaths.SystemConfigurationFilePath, XmlSerializer)); - return _configuration; + if (_configurationLoaded) + { + return _configuration; + } + + lock (_configurationSyncLock) + { + if (_configurationLoaded) + { + return _configuration; + } + + _configuration = (BaseApplicationConfiguration)ConfigurationHelper.GetXmlConfiguration(ConfigurationType, CommonApplicationPaths.SystemConfigurationFilePath, XmlSerializer); + + _configurationLoaded = true; + + return _configuration; + } } + protected set { _configuration = value; @@ -90,26 +135,37 @@ namespace Emby.Server.Implementations.AppBase } } - private ConfigurationStore[] _configurationStores = { }; - private IConfigurationFactory[] _configurationFactories = { }; - /// <summary> - /// Initializes a new instance of the <see cref="BaseConfigurationManager" /> class. + /// Manually pre-loads a factory so that it is available pre system initialisation. /// </summary> - /// <param name="applicationPaths">The application paths.</param> - /// <param name="loggerFactory">The logger factory.</param> - /// <param name="xmlSerializer">The XML serializer.</param> - /// <param name="fileSystem">The file system</param> - protected BaseConfigurationManager(IApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IXmlSerializer xmlSerializer, IFileSystem fileSystem) + /// <typeparam name="T">Class to register.</typeparam> + public virtual void RegisterConfiguration<T>() + where T : IConfigurationFactory { - CommonApplicationPaths = applicationPaths; - XmlSerializer = xmlSerializer; - FileSystem = fileSystem; - Logger = loggerFactory.CreateLogger(GetType().Name); + IConfigurationFactory factory = Activator.CreateInstance<T>(); - UpdateCachePath(); + 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> public virtual void AddParts(IEnumerable<IConfigurationFactory> factories) { _configurationFactories = factories.ToArray(); @@ -151,7 +207,7 @@ namespace Emby.Server.Implementations.AppBase /// Replaces the configuration. /// </summary> /// <param name="newConfiguration">The new configuration.</param> - /// <exception cref="ArgumentNullException">newConfiguration</exception> + /// <exception cref="ArgumentNullException"><c>newConfiguration</c> is <c>null</c>.</exception> public virtual void ReplaceConfiguration(BaseApplicationConfiguration newConfiguration) { if (newConfiguration == null) @@ -171,6 +227,7 @@ namespace Emby.Server.Implementations.AppBase private void UpdateCachePath() { string cachePath; + // If the configuration file has no entry (i.e. not set in UI) if (string.IsNullOrWhiteSpace(CommonConfiguration.CachePath)) { @@ -193,7 +250,7 @@ namespace Emby.Server.Implementations.AppBase cachePath = CommonConfiguration.CachePath; } - Logger.LogInformation("Setting cache path to " + cachePath); + Logger.LogInformation("Setting cache path: {Path}", cachePath); ((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath; } @@ -201,59 +258,70 @@ namespace Emby.Server.Implementations.AppBase /// Replaces the cache path. /// </summary> /// <param name="newConfig">The new configuration.</param> - /// <exception cref="DirectoryNotFoundException"></exception> + /// <exception cref="DirectoryNotFoundException">The new cache path doesn't exist.</exception> private void ValidateCachePath(BaseApplicationConfiguration newConfig) { var newPath = newConfig.CachePath; if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(CommonConfiguration.CachePath ?? string.Empty, newPath)) + && !string.Equals(CommonConfiguration.CachePath ?? string.Empty, newPath, StringComparison.Ordinal)) { // Validate if (!Directory.Exists(newPath)) { - throw new FileNotFoundException(string.Format("{0} does not exist.", newPath)); + throw new DirectoryNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "{0} does not exist.", + newPath)); } EnsureWriteAccess(newPath); } } + /// <summary> + /// Ensures that we have write access to the path. + /// </summary> + /// <param name="path">The path.</param> protected void EnsureWriteAccess(string path) { var file = Path.Combine(path, Guid.NewGuid().ToString()); File.WriteAllText(file, string.Empty); - FileSystem.DeleteFile(file); + _fileSystem.DeleteFile(file); } - private readonly ConcurrentDictionary<string, object> _configurations = new ConcurrentDictionary<string, object>(); - private string GetConfigurationFile(string key) { return Path.Combine(CommonApplicationPaths.ConfigurationDirectoryPath, key.ToLowerInvariant() + ".xml"); } + /// <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) @@ -273,12 +341,13 @@ 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); } } + /// <inheritdoc /> public void SaveConfiguration(string key, object configuration) { var configurationStore = GetConfigurationStore(key); @@ -289,8 +358,7 @@ namespace Emby.Server.Implementations.AppBase throw new ArgumentException("Expected configuration type is " + configurationType.Name); } - var validatingStore = configurationStore as IValidatingConfiguration; - if (validatingStore != null) + if (configurationStore is IValidatingConfiguration validatingStore) { var currentConfiguration = GetConfiguration(key); @@ -316,6 +384,11 @@ namespace Emby.Server.Implementations.AppBase OnNamedConfigurationUpdated(key, configuration); } + /// <summary> + /// Event handler for when a named configuration has been updated. + /// </summary> + /// <param name="key">The key of the configuration.</param> + /// <param name="configuration">The old configuration.</param> protected virtual void OnNamedConfigurationUpdated(string key, object configuration) { NamedConfigurationUpdated?.Invoke(this, new ConfigurationUpdateEventArgs @@ -325,6 +398,7 @@ namespace Emby.Server.Implementations.AppBase }); } + /// <inheritdoc /> public Type GetConfigurationType(string key) { return GetConfigurationStore(key) diff --git a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs index 90b97061f..3a916e5c0 100644 --- a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs +++ b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs @@ -6,13 +6,13 @@ using MediaBrowser.Model.Serialization; namespace Emby.Server.Implementations.AppBase { /// <summary> - /// Class ConfigurationHelper + /// Class ConfigurationHelper. /// </summary> public static class ConfigurationHelper { /// <summary> /// Reads an xml configuration file from the file system - /// It will immediately re-serialize and save if new serialization data is available due to property changes + /// It will immediately re-serialize and save if new serialization data is available due to property changes. /// </summary> /// <param name="type">The type.</param> /// <param name="path">The path.</param> @@ -22,7 +22,7 @@ namespace Emby.Server.Implementations.AppBase { object configuration; - byte[] buffer = null; + byte[]? buffer = null; // Use try/catch to avoid the extra file system lookup using File.Exists try @@ -33,27 +33,31 @@ 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()) + using var stream = new MemoryStream(buffer?.Length ?? 0); + xmlSerializer.SerializeToStream(configuration, stream); + + // Take the object we just got and serialize it back to bytes + Span<byte> newBytes = stream.GetBuffer().AsSpan(0, (int)stream.Length); + + // If the file didn't exist before, or if something has changed, re-save + if (buffer == null || !newBytes.SequenceEqual(buffer)) { - xmlSerializer.SerializeToStream(configuration, stream); + var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); - // Take the object we just got and serialize it back to bytes - var newBytes = stream.ToArray(); + Directory.CreateDirectory(directory); - // If the file didn't exist before, or if something has changed, re-save - if (buffer == null || !buffer.SequenceEqual(newBytes)) + // Save it after load in case we got new items + using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - // Save it after load in case we got new items - File.WriteAllBytes(path, newBytes); + fs.Write(newBytes); } - - return configuration; } + + return configuration; } } } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 042b04b3b..6fd152a42 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -1,5 +1,8 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -9,7 +12,6 @@ using System.Net; 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; @@ -18,7 +20,6 @@ using Emby.Dlna.Ssdp; using Emby.Drawing; using Emby.Notifications; using Emby.Photos; -using Emby.Server.Implementations.Activity; using Emby.Server.Implementations.Archiving; using Emby.Server.Implementations.Channels; using Emby.Server.Implementations.Collections; @@ -26,10 +27,7 @@ using Emby.Server.Implementations.Configuration; using Emby.Server.Implementations.Cryptography; using Emby.Server.Implementations.Data; using Emby.Server.Implementations.Devices; -using Emby.Server.Implementations.Diagnostics; using Emby.Server.Implementations.Dto; -using Emby.Server.Implementations.FFMpeg; -using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; @@ -37,29 +35,29 @@ using Emby.Server.Implementations.LiveTv; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Net; using Emby.Server.Implementations.Playlists; -using Emby.Server.Implementations.Reflection; +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.Session; +using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; +using Emby.Server.Implementations.Udp; using Emby.Server.Implementations.Updates; -using Emby.Server.Implementations.Xml; -using MediaBrowser.Api; +using Jellyfin.Api.Helpers; +using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Manager; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; -using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; using MediaBrowser.Controller; -using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; @@ -73,56 +71,127 @@ 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; using MediaBrowser.Controller.Sorting; using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.MediaEncoding.BdInfo; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Cryptography; -using MediaBrowser.Model.Diagnostics; using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; -using MediaBrowser.Model.Reflection; using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Updates; -using MediaBrowser.Model.Xml; using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Manager; +using MediaBrowser.Providers.Plugins.Tmdb; using MediaBrowser.Providers.Subtitles; -using MediaBrowser.Providers.TV.TheTVDB; -using MediaBrowser.WebDashboard.Api; using MediaBrowser.XbmcMetadata.Providers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.DependencyInjection; -using ServiceStack; -using X509Certificate = System.Security.Cryptography.X509Certificates.X509Certificate; +using Microsoft.Extensions.Logging; +using Prometheus.DotNetRuntime; +using WebSocketManager = Emby.Server.Implementations.HttpServer.WebSocketManager; namespace Emby.Server.Implementations { /// <summary> - /// Class CompositionRoot + /// Class CompositionRoot. /// </summary> public abstract class ApplicationHost : IServerApplicationHost, IDisposable { /// <summary> + /// The environment variable prefixes to log at server startup. + /// </summary> + private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; + + /// <summary> + /// The disposable parts. + /// </summary> + private readonly List<IDisposable> _disposableParts = new List<IDisposable>(); + + private readonly IFileSystem _fileSystemManager; + 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 string[] _urlPrefixes; + + /// <summary> + /// Gets or sets all concrete types. + /// </summary> + /// <value>All concrete types.</value> + private Type[] _allConcreteTypes; + + private DeviceId _deviceId; + + private bool _disposed = false; + + /// <summary> + /// 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, + IServiceCollection serviceCollection) + { + ApplicationPaths = applicationPaths; + LoggerFactory = loggerFactory; + _startupOptions = options; + _startupConfig = startupConfig; + _fileSystemManager = fileSystem; + ServiceCollection = serviceCollection; + + Logger = LoggerFactory.CreateLogger<ApplicationHost>(); + fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); + + ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; + ApplicationVersionString = ApplicationVersion.ToString(3); + ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; + + _xmlSerializer = new MyXmlSerializer(); + ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); + _pluginManager = new PluginManager( + LoggerFactory.CreateLogger<PluginManager>(), + this, + ConfigurationManager.Configuration, + ApplicationPaths.PluginsPath, + ApplicationVersion); + } + + /// <summary> + /// Occurs when [has pending restart changed]. + /// </summary> + public event EventHandler HasPendingRestartChanged; + + /// <summary> /// Gets a value indicating whether this instance can self restart. /// </summary> - /// <value><c>true</c> if this instance can self restart; otherwise, <c>false</c>.</value> - public abstract bool CanSelfRestart { get; } + public bool CanSelfRestart => _startupOptions.RestartPath != null; + + public bool CoreStartupHasCompleted { get; private set; } public virtual bool CanLaunchWebBrowser { @@ -133,255 +202,136 @@ namespace Emby.Server.Implementations return false; } - if (StartupOptions.IsService) + if (_startupOptions.IsService) { return false; } - if (EnvironmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.Windows) - { - return true; - } - - if (EnvironmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.OSX) - { - return true; - } - - return false; + return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); } } /// <summary> - /// Occurs when [has pending restart changed]. - /// </summary> - public event EventHandler HasPendingRestartChanged; - - /// <summary> - /// Occurs when [application updated]. + /// Gets the <see cref="INetworkManager"/> singleton instance. /// </summary> - public event EventHandler<GenericEventArgs<PackageVersionInfo>> ApplicationUpdated; + public INetworkManager NetManager { get; internal set; } /// <summary> - /// Gets or sets a value indicating whether this instance has changes that require the entire application to restart. + /// Gets a value indicating whether this instance has changes that require the entire application to restart. /// </summary> /// <value><c>true</c> if this instance has pending application restart; otherwise, <c>false</c>.</value> public bool HasPendingRestart { get; private set; } + /// <inheritdoc /> public bool IsShuttingDown { get; private set; } /// <summary> - /// Gets or sets the logger. + /// Gets the logger. /// </summary> - /// <value>The logger.</value> - protected ILogger Logger { get; set; } + protected ILogger<ApplicationHost> Logger { get; } - /// <summary> - /// Gets or sets the plugins. - /// </summary> - /// <value>The plugins.</value> - public IPlugin[] Plugins { get; protected set; } + protected IServiceCollection ServiceCollection { get; } /// <summary> - /// Gets or sets the logger factory. + /// Gets the logger factory. /// </summary> - /// <value>The logger factory.</value> - public ILoggerFactory LoggerFactory { get; protected set; } + protected ILoggerFactory LoggerFactory { get; } /// <summary> - /// Gets the application paths. + /// Gets or sets the application paths. /// </summary> /// <value>The application paths.</value> - protected ServerApplicationPaths ApplicationPaths { get; set; } - - /// <summary> - /// Gets all concrete types. - /// </summary> - /// <value>All concrete types.</value> - public Type[] AllConcreteTypes { get; protected set; } - - /// <summary> - /// The disposable parts - /// </summary> - protected readonly List<IDisposable> DisposableParts = new List<IDisposable>(); + protected IServerApplicationPaths ApplicationPaths { get; set; } /// <summary> - /// Gets the configuration manager. + /// Gets or sets the configuration manager. /// </summary> /// <value>The configuration manager.</value> - protected IConfigurationManager ConfigurationManager { get; set; } - - public IFileSystem FileSystemManager { get; set; } - - protected IEnvironmentInfo EnvironmentInfo { get; set; } - - public PackageVersionClass SystemUpdateLevel - { - get - { -#if BETA - return PackageVersionClass.Beta; -#endif - return PackageVersionClass.Release; - } - } - - protected IServiceProvider _serviceProvider; + public ServerConfigurationManager ConfigurationManager { get; set; } /// <summary> - /// Gets the server configuration manager. + /// Gets or sets the service provider. /// </summary> - /// <value>The server configuration manager.</value> - public IServerConfigurationManager ServerConfigurationManager => (IServerConfigurationManager)ConfigurationManager; + public IServiceProvider ServiceProvider { get; set; } /// <summary> - /// Gets the configuration manager. + /// Gets the http port for the webhost. /// </summary> - /// <returns>IConfigurationManager.</returns> - protected IConfigurationManager GetConfigurationManager() - { - return new ServerConfigurationManager(ApplicationPaths, LoggerFactory, XmlSerializer, FileSystemManager); - } - - protected virtual IResourceFileManager CreateResourceFileManager() - { - return new ResourceFileManager(HttpResultFactory, LoggerFactory, FileSystemManager); - } + public int HttpPort { get; private set; } /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } - /// <summary> - /// Gets or sets the library manager. + /// Gets the https port for the webhost. /// </summary> - /// <value>The library manager.</value> - internal ILibraryManager LibraryManager { get; set; } - /// <summary> - /// Gets or sets the directory watchers. - /// </summary> - /// <value>The directory watchers.</value> - private ILibraryMonitor LibraryMonitor { get; set; } - /// <summary> - /// Gets or sets the provider manager. - /// </summary> - /// <value>The provider manager.</value> - private IProviderManager ProviderManager { get; set; } - /// <summary> - /// Gets or sets the HTTP server. - /// </summary> - /// <value>The HTTP server.</value> - private IHttpServer HttpServer { get; set; } - private IDtoService DtoService { get; set; } - public IImageProcessor ImageProcessor { get; set; } + public int HttpsPort { get; private set; } /// <summary> - /// Gets or sets the media encoder. + /// Gets the value of the PublishedServerUrl setting. /// </summary> - /// <value>The media encoder.</value> - private IMediaEncoder MediaEncoder { get; set; } - private ISubtitleEncoder SubtitleEncoder { get; set; } - - private ISessionManager SessionManager { get; set; } + public string PublishedServerUrl => _startupOptions.PublishedServerUrl ?? _startupConfig[UdpServer.AddressOverrideConfigKey]; - private ILiveTvManager LiveTvManager { get; set; } + /// <inheritdoc /> + public Version ApplicationVersion { get; } - public LocalizationManager LocalizationManager { get; set; } - - private IEncodingManager EncodingManager { get; set; } - private IChannelManager ChannelManager { get; set; } + /// <inheritdoc /> + public string ApplicationVersionString { get; } /// <summary> - /// Gets or sets the user data repository. + /// Gets the current application user agent. /// </summary> - /// <value>The user data repository.</value> - private IUserDataManager UserDataManager { get; set; } - private IUserRepository UserRepository { get; set; } - internal SqliteItemRepository ItemRepository { get; set; } - - private INotificationManager NotificationManager { get; set; } - private ISubtitleManager SubtitleManager { get; set; } - private IChapterManager ChapterManager { get; set; } - private IDeviceManager DeviceManager { get; set; } - - internal IUserViewManager UserViewManager { get; set; } - - private IAuthenticationRepository AuthenticationRepository { get; set; } - private ITVSeriesManager TVSeriesManager { get; set; } - private ICollectionManager CollectionManager { get; set; } - private IMediaSourceManager MediaSourceManager { get; set; } - private IPlaylistManager PlaylistManager { get; set; } - - private readonly IConfiguration _configuration; + /// <value>The application user agent.</value> + public string ApplicationUserAgent { get; } /// <summary> - /// Gets or sets the installation manager. + /// 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> - /// <value>The installation manager.</value> - protected IInstallationManager InstallationManager { get; private set; } + public string ApplicationUserAgentAddress => "team@jellyfin.org"; /// <summary> - /// Gets or sets the zip client. + /// Gets the current application name. /// </summary> - /// <value>The zip client.</value> - protected IZipClient ZipClient { get; private set; } - protected IHttpResultFactory HttpResultFactory { get; private set; } - protected IAuthService AuthService { get; private set; } - - public IStartupOptions StartupOptions { get; private set; } - - internal IImageEncoder ImageEncoder { get; private set; } - - protected IProcessFactory ProcessFactory { get; private set; } - protected ICryptoProvider CryptographyProvider = new CryptographyProvider(); - protected readonly IXmlSerializer XmlSerializer; - - protected ISocketFactory SocketFactory { get; private set; } - protected ITaskManager TaskManager { get; private set; } - public IHttpClient HttpClient { get; private set; } - protected INetworkManager NetworkManager { get; set; } - public IJsonSerializer JsonSerializer { get; private set; } - protected IIsoManager IsoManager { get; private set; } + /// <value>The application name.</value> + public string ApplicationProductName { get; } = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductName; - /// <summary> - /// Initializes a new instance of the <see cref="ApplicationHost" /> class. - /// </summary> - public ApplicationHost(ServerApplicationPaths applicationPaths, - ILoggerFactory loggerFactory, - IStartupOptions options, - IFileSystem fileSystem, - IEnvironmentInfo environmentInfo, - IImageEncoder imageEncoder, - INetworkManager networkManager, - IConfiguration configuration) + public string SystemId { - _configuration = configuration; - - // hack alert, until common can target .net core - BaseExtensions.CryptographyProvider = CryptographyProvider; - - XmlSerializer = new MyXmlSerializer(fileSystem, loggerFactory); - - NetworkManager = networkManager; - networkManager.LocalSubnetsFn = GetConfiguredLocalSubnets; - EnvironmentInfo = environmentInfo; + get + { + _deviceId ??= new DeviceId(ApplicationPaths, LoggerFactory); - ApplicationPaths = applicationPaths; - LoggerFactory = loggerFactory; - FileSystemManager = fileSystem; + return _deviceId.Value; + } + } - ConfigurationManager = GetConfigurationManager(); + /// <inheritdoc/> + public string Name => ApplicationProductName; - Logger = LoggerFactory.CreateLogger("App"); + private string CertificatePath { get; set; } - StartupOptions = options; + public X509Certificate2 Certificate { get; private set; } - ImageEncoder = imageEncoder; + /// <inheritdoc/> + public bool ListenWithHttps => Certificate != null && ConfigurationManager.GetNetworkConfiguration().EnableHttps; - fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); + public string FriendlyName => + string.IsNullOrEmpty(ConfigurationManager.Configuration.ServerName) + ? Environment.MachineName + : ConfigurationManager.Configuration.ServerName; - NetworkManager.NetworkChanged += NetworkManager_NetworkChanged; + /// <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)) + { + var networkSettings = new NetworkConfiguration(); + ClassMigrationHelper.CopyProperties(ConfigurationManager.Configuration, networkSettings); + _xmlSerializer.SerializeToFile(networkSettings, path); + Logger.LogDebug("Successfully migrated network settings."); + } } public string ExpandVirtualPath(string path) @@ -400,124 +350,114 @@ namespace Emby.Server.Implementations .Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase); } - private string[] GetConfiguredLocalSubnets() - { - return ServerConfigurationManager.Configuration.LocalNetworkSubnets; - } - - private void NetworkManager_NetworkChanged(object sender, EventArgs e) - { - _validAddressResults.Clear(); - } - - public string ApplicationVersion => typeof(ApplicationHost).Assembly.GetName().Version.ToString(3); - - /// <summary> - /// Gets the current application user agent - /// </summary> - /// <value>The application user agent.</value> - public string ApplicationUserAgent => Name.Replace(' ','-') + "/" + ApplicationVersion; - - private string _productName; - /// <summary> - /// Gets the current application name - /// </summary> - /// <value>The application name.</value> - public string ApplicationProductName => _productName ?? (_productName = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductName); - - private DeviceId _deviceId; - public string SystemId - { - get - { - if (_deviceId == null) - { - _deviceId = new DeviceId(ApplicationPaths, LoggerFactory); - } - - return _deviceId.Value; - } - } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ApplicationProductName; - /// <summary> - /// Creates an instance of type and resolves all constructor dependencies + /// Creates an instance of type and resolves all constructor dependencies. /// </summary> /// <param name="type">The type.</param> /// <returns>System.Object.</returns> public object CreateInstance(Type type) - => ActivatorUtilities.CreateInstance(_serviceProvider, type); + => ActivatorUtilities.CreateInstance(ServiceProvider, type); /// <summary> - /// Creates an instance of type and resolves all constructor dependencies + /// Creates an instance of type and resolves all constructor dependencies. /// </summary> - /// <param name="type">The type.</param> - /// <returns>System.Object.</returns> + /// <typeparam name="T">The type.</typeparam> + /// <returns>T.</returns> public T CreateInstance<T>() - => ActivatorUtilities.CreateInstance<T>(_serviceProvider); + => ActivatorUtilities.CreateInstance<T>(ServiceProvider); /// <summary> /// Creates the instance safe. /// </summary> - /// <param name="typeInfo">The type information.</param> + /// <param name="type">The type.</param> /// <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); + 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"></typeparam> + /// <typeparam name="T">The type.</typeparam> /// <returns>``0.</returns> - public T Resolve<T>() => _serviceProvider.GetService<T>(); + public T Resolve<T>() => ServiceProvider.GetService<T>(); - /// <summary> - /// Gets the export types. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <returns>IEnumerable{Type}.</returns> + /// <inheritdoc/> public IEnumerable<Type> GetExportTypes<T>() { var currentType = typeof(T); - return AllConcreteTypes.Where(i => currentType.IsAssignableFrom(i)); + return _allConcreteTypes.Where(i => currentType.IsAssignableFrom(i)); } - /// <summary> - /// Gets the exports. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="manageLifetime">if set to <c>true</c> [manage lifetime].</param> - /// <returns>IEnumerable{``0}.</returns> - public IEnumerable<T> GetExports<T>(bool manageLifetime = true) + /// <inheritdoc /> + public IReadOnlyCollection<T> GetExports<T>(bool manageLifetime = true) { + // Convert to list so this isn't executed for each iteration var parts = GetExportTypes<T>() - .Select(x => CreateInstanceSafe(x)) + .Select(CreateInstanceSafe) .Where(i => i != null) .Cast<T>() - .ToList(); // Convert to list so this isn't executed for each iteration + .ToList(); if (manageLifetime) { - lock (DisposableParts) + lock (_disposableParts) { - DisposableParts.AddRange(parts.OfType<IDisposable>()); + _disposableParts.AddRange(parts.OfType<IDisposable>()); + } + } + + 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>()); } } @@ -527,39 +467,42 @@ namespace Emby.Server.Implementations /// <summary> /// Runs the startup tasks. /// </summary> - public async Task RunStartupTasks() + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns><see cref="Task" />.</returns> + 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.Init(); - - //if (string.IsNullOrWhiteSpace(MediaEncoder.EncoderPath)) - //{ - // if (ServerConfigurationManager.Configuration.IsStartupWizardCompleted) - // { - // ServerConfigurationManager.Configuration.IsStartupWizardCompleted = false; - // ServerConfigurationManager.SaveConfiguration(); - // } - //} + _mediaEncoder.SetFFmpegPath(); - Logger.LogInformation("ServerId: {0}", SystemId); + Logger.LogInformation("ServerId: {ServerId}", SystemId); var entryPoints = GetExports<IServerEntryPoint>(); - var now = DateTime.UtcNow; - await Task.WhenAll(StartEntryPoints(entryPoints, true)); - Logger.LogInformation("Executed all pre-startup entry points in {Elapsed:fff} ms", DateTime.Now - now); + 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(); - now = DateTime.UtcNow; - await Task.WhenAll(StartEntryPoints(entryPoints, false)); - Logger.LogInformation("Executed all post-startup entry points in {Elapsed:fff} ms", DateTime.Now - now); + 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(); } private IEnumerable<Task> StartEntryPoints(IEnumerable<IServerEntryPoint> entryPoints, bool isBeforeStartup) @@ -577,318 +520,232 @@ namespace Emby.Server.Implementations } } - public async Task Init(IServiceCollection serviceCollection) + /// <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>()); - JsonSerializer = new JsonSerializer(FileSystemManager); + // Have to migrate settings here as migration subsystem not yet initialised. + MigrateNetworkConfiguration(); + NetManager = new NetworkManager(ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>()); - if (Plugins != null) + // Initialize runtime stat collection + if (ConfigurationManager.Configuration.EnableMetrics) { - var pluginBuilder = new StringBuilder(); - - foreach (var plugin in Plugins) - { - pluginBuilder.AppendLine(string.Format("{0} {1}", plugin.Name, plugin.Version)); - } - - Logger.LogInformation("Plugins: {plugins}", pluginBuilder.ToString()); + DotNetRuntimeStatsBuilder.Default().StartCollecting(); } - DiscoverTypes(); + var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); + HttpPort = networkConfiguration.HttpServerPortNumber; + HttpsPort = networkConfiguration.HttpsPortNumber; - SetHttpLimit(); + // Safeguard against invalid configuration + if (HttpPort == HttpsPort) + { + HttpPort = NetworkConfiguration.DefaultHttpPort; + HttpsPort = NetworkConfiguration.DefaultHttpsPort; + } - await RegisterResources(serviceCollection); + CertificatePath = networkConfiguration.CertificatePath; + Certificate = GetCertificate(CertificatePath, networkConfiguration.CertificatePassword); - FindParts(); - } + RegisterServices(); - protected virtual IHttpClient CreateHttpClient() - { - return new HttpClientManager.HttpClientManager(ApplicationPaths, LoggerFactory, FileSystemManager, () => ApplicationUserAgent); + _pluginManager.RegisterServices(ServiceCollection); } - public static IStreamHelper StreamHelper { get; set; } - /// <summary> - /// Registers resources that classes will depend on + /// Registers services/resources with the service collection that will be available via DI. /// </summary> - protected async Task RegisterResources(IServiceCollection serviceCollection) + protected virtual void RegisterServices() { - serviceCollection.AddMemoryCache(); - - serviceCollection.AddSingleton(ConfigurationManager); - serviceCollection.AddSingleton<IApplicationHost>(this); - - serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - - serviceCollection.AddSingleton(JsonSerializer); - - serviceCollection.AddSingleton(LoggerFactory); - serviceCollection.AddLogging(); - serviceCollection.AddSingleton(Logger); - - serviceCollection.AddSingleton(EnvironmentInfo); - - serviceCollection.AddSingleton(FileSystemManager); - serviceCollection.AddSingleton<TvDbClientManager>(); - - HttpClient = CreateHttpClient(); - serviceCollection.AddSingleton(HttpClient); - - serviceCollection.AddSingleton(NetworkManager); - - IsoManager = new IsoManager(); - serviceCollection.AddSingleton(IsoManager); - - TaskManager = new TaskManager(ApplicationPaths, JsonSerializer, LoggerFactory, FileSystemManager); - serviceCollection.AddSingleton(TaskManager); - - serviceCollection.AddSingleton(XmlSerializer); + ServiceCollection.AddSingleton(_startupOptions); - ProcessFactory = new ProcessFactory(); - serviceCollection.AddSingleton(ProcessFactory); + ServiceCollection.AddMemoryCache(); - ApplicationHost.StreamHelper = new StreamHelper(); - serviceCollection.AddSingleton(StreamHelper); + ServiceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager); + ServiceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager); + ServiceCollection.AddSingleton<IApplicationHost>(this); + ServiceCollection.AddSingleton<IPluginManager>(_pluginManager); + ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - serviceCollection.AddSingleton(CryptographyProvider); + ServiceCollection.AddSingleton(_fileSystemManager); + ServiceCollection.AddSingleton<TmdbClientManager>(); - SocketFactory = new SocketFactory(); - serviceCollection.AddSingleton(SocketFactory); + ServiceCollection.AddSingleton(NetManager); - InstallationManager = new InstallationManager(LoggerFactory, this, ApplicationPaths, HttpClient, JsonSerializer, ServerConfigurationManager, FileSystemManager, CryptographyProvider, ZipClient, PackageRuntime); - serviceCollection.AddSingleton(InstallationManager); + ServiceCollection.AddSingleton<ITaskManager, TaskManager>(); - ZipClient = new ZipClient(); - serviceCollection.AddSingleton(ZipClient); + ServiceCollection.AddSingleton(_xmlSerializer); - HttpResultFactory = new HttpResultFactory(LoggerFactory, FileSystemManager, JsonSerializer, CreateBrotliCompressor()); - serviceCollection.AddSingleton(HttpResultFactory); + ServiceCollection.AddSingleton<IStreamHelper, StreamHelper>(); - serviceCollection.AddSingleton<IServerApplicationHost>(this); - serviceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths); + ServiceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>(); - serviceCollection.AddSingleton(ServerConfigurationManager); + ServiceCollection.AddSingleton<ISocketFactory, SocketFactory>(); - var assemblyInfo = new AssemblyInfo(); - serviceCollection.AddSingleton<IAssemblyInfo>(assemblyInfo); + ServiceCollection.AddSingleton<IInstallationManager, InstallationManager>(); - LocalizationManager = new LocalizationManager(ServerConfigurationManager, FileSystemManager, JsonSerializer, LoggerFactory); - await LocalizationManager.LoadAll(); - serviceCollection.AddSingleton<ILocalizationManager>(LocalizationManager); + ServiceCollection.AddSingleton<IZipClient, ZipClient>(); - serviceCollection.AddSingleton<IBlurayExaminer>(new BdInfoExaminer(FileSystemManager)); + ServiceCollection.AddSingleton<IServerApplicationHost>(this); + ServiceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths); - serviceCollection.AddSingleton<IXmlReaderSettingsFactory>(new XmlReaderSettingsFactory()); + ServiceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); - UserDataManager = new UserDataManager(LoggerFactory, ServerConfigurationManager, () => UserManager); - serviceCollection.AddSingleton(UserDataManager); + ServiceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); - UserRepository = GetUserRepository(); - // This is only needed for disposal purposes. If removing this, make sure to have the manager handle disposing it - serviceCollection.AddSingleton(UserRepository); + ServiceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>(); + ServiceCollection.AddSingleton<IUserDataManager, UserDataManager>(); - var displayPreferencesRepo = new SqliteDisplayPreferencesRepository(LoggerFactory, JsonSerializer, ApplicationPaths, FileSystemManager); - serviceCollection.AddSingleton<IDisplayPreferencesRepository>(displayPreferencesRepo); + ServiceCollection.AddSingleton<IItemRepository, SqliteItemRepository>(); - ItemRepository = new SqliteItemRepository(ServerConfigurationManager, this, JsonSerializer, LoggerFactory, assemblyInfo); - serviceCollection.AddSingleton<IItemRepository>(ItemRepository); + ServiceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>(); + ServiceCollection.AddSingleton<EncodingHelper>(); - AuthenticationRepository = GetAuthenticationRepository(); - serviceCollection.AddSingleton(AuthenticationRepository); + // TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required + ServiceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); + ServiceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); + ServiceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); + ServiceCollection.AddSingleton<ILibraryManager, LibraryManager>(); - UserManager = new UserManager(LoggerFactory, ServerConfigurationManager, UserRepository, XmlSerializer, NetworkManager, () => ImageProcessor, () => DtoService, this, JsonSerializer, FileSystemManager); - serviceCollection.AddSingleton(UserManager); + ServiceCollection.AddSingleton<IMusicManager, MusicManager>(); - LibraryManager = new LibraryManager(this, LoggerFactory, TaskManager, UserManager, ServerConfigurationManager, UserDataManager, () => LibraryMonitor, FileSystemManager, () => ProviderManager, () => UserViewManager); - serviceCollection.AddSingleton(LibraryManager); + ServiceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); - // TODO wtaylor: investigate use of second music manager - var musicManager = new MusicManager(LibraryManager); - serviceCollection.AddSingleton<IMusicManager>(new MusicManager(LibraryManager)); + ServiceCollection.AddSingleton<ISearchEngine, SearchEngine>(); - LibraryMonitor = new LibraryMonitor(LoggerFactory, LibraryManager, ServerConfigurationManager, FileSystemManager, EnvironmentInfo); - serviceCollection.AddSingleton(LibraryMonitor); + ServiceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); - serviceCollection.AddSingleton<ISearchEngine>(new SearchEngine(LoggerFactory, LibraryManager, UserManager)); + ServiceCollection.AddSingleton<IImageProcessor, ImageProcessor>(); - CertificateInfo = GetCertificateInfo(true); - Certificate = GetCertificate(CertificateInfo); + ServiceCollection.AddSingleton<ITVSeriesManager, TVSeriesManager>(); - HttpServer = new HttpListenerHost(this, - LoggerFactory, - ServerConfigurationManager, - _configuration, - NetworkManager, - JsonSerializer, - XmlSerializer); + ServiceCollection.AddSingleton<IMediaSourceManager, MediaSourceManager>(); - HttpServer.GlobalResponse = LocalizationManager.GetLocalizedString("StartupEmbyServerIsLoading"); - serviceCollection.AddSingleton(HttpServer); + ServiceCollection.AddSingleton<ISubtitleManager, SubtitleManager>(); - ImageProcessor = GetImageProcessor(); - serviceCollection.AddSingleton(ImageProcessor); + ServiceCollection.AddSingleton<IProviderManager, ProviderManager>(); - TVSeriesManager = new TVSeriesManager(UserManager, UserDataManager, LibraryManager, ServerConfigurationManager); - serviceCollection.AddSingleton(TVSeriesManager); + // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required + ServiceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>)); + ServiceCollection.AddSingleton<IDtoService, DtoService>(); - var encryptionManager = new EncryptionManager(); - serviceCollection.AddSingleton<IEncryptionManager>(encryptionManager); + ServiceCollection.AddSingleton<IChannelManager, ChannelManager>(); - DeviceManager = new DeviceManager(AuthenticationRepository, JsonSerializer, LibraryManager, LocalizationManager, UserManager, FileSystemManager, LibraryMonitor, ServerConfigurationManager); - serviceCollection.AddSingleton(DeviceManager); + ServiceCollection.AddSingleton<ISessionManager, SessionManager>(); - MediaSourceManager = new MediaSourceManager(ItemRepository, ApplicationPaths, LocalizationManager, UserManager, LibraryManager, LoggerFactory, JsonSerializer, FileSystemManager, UserDataManager, () => MediaEncoder); - serviceCollection.AddSingleton(MediaSourceManager); + ServiceCollection.AddSingleton<IDlnaManager, DlnaManager>(); - SubtitleManager = new SubtitleManager(LoggerFactory, FileSystemManager, LibraryMonitor, MediaSourceManager, LocalizationManager); - serviceCollection.AddSingleton(SubtitleManager); + ServiceCollection.AddSingleton<ICollectionManager, CollectionManager>(); - ProviderManager = new ProviderManager(HttpClient, SubtitleManager, ServerConfigurationManager, LibraryMonitor, LoggerFactory, FileSystemManager, ApplicationPaths, () => LibraryManager, JsonSerializer); - serviceCollection.AddSingleton(ProviderManager); + ServiceCollection.AddSingleton<IPlaylistManager, PlaylistManager>(); - DtoService = new DtoService(LoggerFactory, LibraryManager, UserDataManager, ItemRepository, ImageProcessor, ProviderManager, this, () => MediaSourceManager, () => LiveTvManager); - serviceCollection.AddSingleton(DtoService); + ServiceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>(); - ChannelManager = new ChannelManager(UserManager, DtoService, LibraryManager, LoggerFactory, ServerConfigurationManager, FileSystemManager, UserDataManager, JsonSerializer, LocalizationManager, HttpClient, ProviderManager); - serviceCollection.AddSingleton(ChannelManager); + ServiceCollection.AddSingleton<LiveTvDtoService>(); + ServiceCollection.AddSingleton<ILiveTvManager, LiveTvManager>(); - SessionManager = new SessionManager(UserDataManager, LoggerFactory, LibraryManager, UserManager, musicManager, DtoService, ImageProcessor, JsonSerializer, this, HttpClient, AuthenticationRepository, DeviceManager, MediaSourceManager); - serviceCollection.AddSingleton(SessionManager); + ServiceCollection.AddSingleton<IUserViewManager, UserViewManager>(); - serviceCollection.AddSingleton<IDlnaManager>( - new DlnaManager(XmlSerializer, FileSystemManager, ApplicationPaths, LoggerFactory, JsonSerializer, this, assemblyInfo)); + ServiceCollection.AddSingleton<INotificationManager, NotificationManager>(); - CollectionManager = new CollectionManager(LibraryManager, ApplicationPaths, LocalizationManager, FileSystemManager, LibraryMonitor, LoggerFactory, ProviderManager); - serviceCollection.AddSingleton(CollectionManager); + ServiceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); - PlaylistManager = new PlaylistManager(LibraryManager, FileSystemManager, LibraryMonitor, LoggerFactory, UserManager, ProviderManager); - serviceCollection.AddSingleton(PlaylistManager); + ServiceCollection.AddSingleton<IChapterManager, ChapterManager>(); - LiveTvManager = new LiveTvManager(this, ServerConfigurationManager, LoggerFactory, ItemRepository, ImageProcessor, UserDataManager, DtoService, UserManager, LibraryManager, TaskManager, LocalizationManager, JsonSerializer, FileSystemManager, () => ChannelManager); - serviceCollection.AddSingleton(LiveTvManager); + ServiceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>(); - UserViewManager = new UserViewManager(LibraryManager, LocalizationManager, UserManager, ChannelManager, LiveTvManager, ServerConfigurationManager); - serviceCollection.AddSingleton(UserViewManager); + ServiceCollection.AddScoped<ISessionContext, SessionContext>(); - NotificationManager = new NotificationManager(LoggerFactory, UserManager, ServerConfigurationManager); - serviceCollection.AddSingleton(NotificationManager); + ServiceCollection.AddSingleton<IAuthService, AuthService>(); + ServiceCollection.AddSingleton<IQuickConnect, QuickConnectManager>(); - serviceCollection.AddSingleton<IDeviceDiscovery>( - new DeviceDiscovery(LoggerFactory, ServerConfigurationManager, SocketFactory)); + ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>(); - ChapterManager = new ChapterManager(LibraryManager, LoggerFactory, ServerConfigurationManager, ItemRepository); - serviceCollection.AddSingleton(ChapterManager); + ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); - RegisterMediaEncoder(serviceCollection); + ServiceCollection.AddSingleton<TranscodingJobHelper>(); + ServiceCollection.AddScoped<MediaInfoHelper>(); + ServiceCollection.AddScoped<AudioHelper>(); + ServiceCollection.AddScoped<DynamicHlsHelper>(); - EncodingManager = new MediaEncoder.EncodingManager(FileSystemManager, LoggerFactory, MediaEncoder, ChapterManager, LibraryManager); - serviceCollection.AddSingleton(EncodingManager); - - var activityLogRepo = GetActivityLogRepository(); - serviceCollection.AddSingleton(activityLogRepo); - serviceCollection.AddSingleton<IActivityManager>(new ActivityManager(LoggerFactory, activityLogRepo, UserManager)); - - var authContext = new AuthorizationContext(AuthenticationRepository, UserManager); - serviceCollection.AddSingleton<IAuthorizationContext>(authContext); - serviceCollection.AddSingleton<ISessionContext>(new SessionContext(UserManager, authContext, SessionManager)); - - AuthService = new AuthService(UserManager, authContext, ServerConfigurationManager, SessionManager, NetworkManager); - serviceCollection.AddSingleton(AuthService); - - SubtitleEncoder = new MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder(LibraryManager, LoggerFactory, ApplicationPaths, FileSystemManager, MediaEncoder, JsonSerializer, HttpClient, MediaSourceManager, ProcessFactory); - serviceCollection.AddSingleton(SubtitleEncoder); - - serviceCollection.AddSingleton(CreateResourceFileManager()); + ServiceCollection.AddSingleton<IDirectoryService, DirectoryService>(); + } - displayPreferencesRepo.Initialize(); + /// <summary> + /// Create services registered with the service container that need to be initialized at application startup. + /// </summary> + /// <returns>A task representing the service initialization operation.</returns> + public async Task InitializeServices() + { + var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>(); + await localizationManager.LoadAll().ConfigureAwait(false); - var userDataRepo = new SqliteUserDataRepository(LoggerFactory, ApplicationPaths); + _mediaEncoder = Resolve<IMediaEncoder>(); + _sessionManager = Resolve<ISessionManager>(); SetStaticProperties(); - ((UserManager)UserManager).Initialize(); + var userDataRepo = (SqliteUserDataRepository)Resolve<IUserDataRepository>(); + ((SqliteItemRepository)Resolve<IItemRepository>()).Initialize(userDataRepo, Resolve<IUserManager>()); - ((UserDataManager)UserDataManager).Repository = userDataRepo; - ItemRepository.Initialize(userDataRepo, UserManager); - ((LibraryManager)LibraryManager).ItemRepository = ItemRepository; - - _serviceProvider = serviceCollection.BuildServiceProvider(); - } - - protected virtual IBrotliCompressor CreateBrotliCompressor() - { - return null; + FindParts(); } - public virtual string PackageRuntime => "netcore"; - - public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths, EnvironmentInfo.EnvironmentInfo environmentInfo) + public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths) { // Distinct these to prevent users from reporting problems that aren't actually problems var commandLineArgs = Environment .GetCommandLineArgs() .Distinct(); + // Get all relevant environment variables + var allEnvVars = Environment.GetEnvironmentVariables(); + var relevantEnvVars = new Dictionary<object, object>(); + foreach (var key in allEnvVars.Keys) + { + if (_relevantEnvVarPrefixes.Any(prefix => key.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + { + relevantEnvVars.Add(key, allEnvVars[key]); + } + } + + logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); logger.LogInformation("Arguments: {Args}", commandLineArgs); - logger.LogInformation("Operating system: {OS} {OSVersion}", environmentInfo.OperatingSystemName, environmentInfo.OperatingSystemVersion); - logger.LogInformation("Architecture: {Architecture}", environmentInfo.SystemArchitecture); + 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); logger.LogInformation("Processor count: {ProcessorCount}", Environment.ProcessorCount); logger.LogInformation("Program data path: {ProgramDataPath}", appPaths.ProgramDataPath); + logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath); logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); } - private void SetHttpLimit() + private X509Certificate2 GetCertificate(string path, string password) { - try - { - // Increase the max http request limit - ServicePointManager.DefaultConnectionLimit = Math.Max(96, ServicePointManager.DefaultConnectionLimit); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error setting http limit"); - } - } - - protected virtual bool SupportsDualModeSockets => true; - - private X509Certificate GetCertificate(CertificateInfo info) - { - var certificateLocation = info == null ? null : info.Path; - - if (string.IsNullOrWhiteSpace(certificateLocation)) + if (string.IsNullOrWhiteSpace(path)) { return null; } try { - if (!File.Exists(certificateLocation)) + if (!File.Exists(path)) { return null; } // Don't use an empty string password - var password = string.IsNullOrWhiteSpace(info.Password) ? null : info.Password; + password = string.IsNullOrWhiteSpace(password) ? null : password; - var localCert = new X509Certificate2(certificateLocation, password); - //localCert.PrivateKey = PrivateKey.CreateFromFile(pvk_file).RSA; + var localCert = new X509Certificate2(path, password, X509KeyStorageFlags.UserKeySet); if (!localCert.HasPrivateKey) { - Logger.LogError("No private key included in SSL cert {CertificateLocation}.", certificateLocation); + Logger.LogError("No private key included in SSL cert {CertificateLocation}.", path); return null; } @@ -896,269 +753,117 @@ namespace Emby.Server.Implementations } catch (Exception ex) { - Logger.LogError(ex, "Error loading cert from {CertificateLocation}", certificateLocation); + Logger.LogError(ex, "Error loading cert from {CertificateLocation}", path); return null; } } - private IImageProcessor GetImageProcessor() - { - return new ImageProcessor(LoggerFactory, ServerConfigurationManager.ApplicationPaths, FileSystemManager, ImageEncoder, () => LibraryManager, () => MediaEncoder); - } - - protected virtual FFMpegInstallInfo GetFfmpegInstallInfo() - { - var info = new FFMpegInstallInfo(); - - // Windows builds: http://ffmpeg.zeranoe.com/builds/ - // Linux builds: http://johnvansickle.com/ffmpeg/ - // OS X builds: http://ffmpegmac.net/ - // OS X x64: http://www.evermeet.cx/ffmpeg/ - - if (EnvironmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.Linux) - { - info.FFMpegFilename = "ffmpeg"; - info.FFProbeFilename = "ffprobe"; - info.ArchiveType = "7z"; - info.Version = "20170308"; - } - else if (EnvironmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.Windows) - { - info.FFMpegFilename = "ffmpeg.exe"; - info.FFProbeFilename = "ffprobe.exe"; - info.Version = "20170308"; - info.ArchiveType = "7z"; - } - else if (EnvironmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.OSX) - { - info.FFMpegFilename = "ffmpeg"; - info.FFProbeFilename = "ffprobe"; - info.ArchiveType = "7z"; - info.Version = "20170308"; - } - - return info; - } - - protected virtual FFMpegInfo GetFFMpegInfo() - { - return new FFMpegLoader(ApplicationPaths, FileSystemManager, GetFfmpegInstallInfo()) - .GetFFMpegInfo(StartupOptions); - } - /// <summary> - /// Registers the media encoder. - /// </summary> - /// <returns>Task.</returns> - private void RegisterMediaEncoder(IServiceCollection serviceCollection) - { - string encoderPath = null; - string probePath = null; - - var info = GetFFMpegInfo(); - - encoderPath = info.EncoderPath; - probePath = info.ProbePath; - var hasExternalEncoder = string.Equals(info.Version, "external", StringComparison.OrdinalIgnoreCase); - - var mediaEncoder = new MediaBrowser.MediaEncoding.Encoder.MediaEncoder( - LoggerFactory, - JsonSerializer, - encoderPath, - probePath, - hasExternalEncoder, - ServerConfigurationManager, - FileSystemManager, - LiveTvManager, - IsoManager, - LibraryManager, - ChannelManager, - SessionManager, - () => SubtitleEncoder, - () => MediaSourceManager, - HttpClient, - ZipClient, - ProcessFactory, - 5000); - - MediaEncoder = mediaEncoder; - serviceCollection.AddSingleton(MediaEncoder); - } - - /// <summary> - /// Gets the user repository. - /// </summary> - /// <returns>Task{IUserRepository}.</returns> - private IUserRepository GetUserRepository() - { - var repo = new SqliteUserRepository(LoggerFactory, ApplicationPaths, JsonSerializer); - - repo.Initialize(); - - return repo; - } - - private IAuthenticationRepository GetAuthenticationRepository() - { - var repo = new AuthenticationRepository(LoggerFactory, ServerConfigurationManager); - - repo.Initialize(); - - return repo; - } - - private IActivityRepository GetActivityLogRepository() - { - var repo = new ActivityRepository(LoggerFactory, ServerConfigurationManager.ApplicationPaths, FileSystemManager); - - repo.Initialize(); - - return repo; - } - - /// <summary> - /// Dirty hacks + /// Dirty hacks. /// </summary> private void SetStaticProperties() { - ((SqliteItemRepository)ItemRepository).ImageProcessor = ImageProcessor; - // For now there's no real way to inject these properly - BaseItem.Logger = LoggerFactory.CreateLogger("BaseItem"); - BaseItem.ConfigurationManager = ServerConfigurationManager; - BaseItem.LibraryManager = LibraryManager; - BaseItem.ProviderManager = ProviderManager; - BaseItem.LocalizationManager = LocalizationManager; - BaseItem.ItemRepository = ItemRepository; - User.UserManager = UserManager; - BaseItem.FileSystem = FileSystemManager; - BaseItem.UserDataManager = UserDataManager; - BaseItem.ChannelManager = ChannelManager; - Video.LiveTvManager = LiveTvManager; - Folder.UserViewManager = UserViewManager; - UserView.TVSeriesManager = TVSeriesManager; - UserView.PlaylistManager = PlaylistManager; - UserView.CollectionManager = CollectionManager; - BaseItem.MediaSourceManager = MediaSourceManager; - CollectionFolder.XmlSerializer = XmlSerializer; - CollectionFolder.JsonSerializer = JsonSerializer; + BaseItem.Logger = Resolve<ILogger<BaseItem>>(); + BaseItem.ConfigurationManager = ConfigurationManager; + BaseItem.LibraryManager = Resolve<ILibraryManager>(); + BaseItem.ProviderManager = Resolve<IProviderManager>(); + BaseItem.LocalizationManager = Resolve<ILocalizationManager>(); + BaseItem.ItemRepository = Resolve<IItemRepository>(); + BaseItem.FileSystem = _fileSystemManager; + BaseItem.UserDataManager = Resolve<IUserDataManager>(); + BaseItem.ChannelManager = Resolve<IChannelManager>(); + Video.LiveTvManager = Resolve<ILiveTvManager>(); + Folder.UserViewManager = Resolve<IUserViewManager>(); + UserView.TVSeriesManager = Resolve<ITVSeriesManager>(); + UserView.CollectionManager = Resolve<ICollectionManager>(); + BaseItem.MediaSourceManager = Resolve<IMediaSourceManager>(); + CollectionFolder.XmlSerializer = _xmlSerializer; CollectionFolder.ApplicationHost = this; - AuthenticatedAttribute.AuthService = AuthService; } /// <summary> - /// Finds the parts. + /// Finds plugin components and register them with the appropriate services. /// </summary> - protected void FindParts() + 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(); - - HttpServer.Init(GetExports<IService>(false), GetExports<IWebSocketListener>()); + _pluginManager.CreatePlugins(); - StartServer(); + _urlPrefixes = GetUrlPrefixes().ToArray(); - LibraryManager.AddParts(GetExports<IResolverIgnoreRule>(), + Resolve<ILibraryManager>().AddParts( + GetExports<IResolverIgnoreRule>(), GetExports<IItemResolver>(), GetExports<IIntroProvider>(), GetExports<IBaseItemComparer>(), GetExports<ILibraryPostScanTask>()); - ProviderManager.AddParts(GetExports<IImageProvider>(), + Resolve<IProviderManager>().AddParts( + GetExports<IImageProvider>(), GetExports<IMetadataService>(), GetExports<IMetadataProvider>(), GetExports<IMetadataSaver>(), GetExports<IExternalId>()); - ImageProcessor.AddParts(GetExports<IImageEnhancer>()); + Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<ITunerHost>(), GetExports<IListingsProvider>()); - LiveTvManager.AddParts(GetExports<ILiveTvService>(), GetExports<ITunerHost>(), GetExports<IListingsProvider>()); + Resolve<ISubtitleManager>().AddParts(GetExports<ISubtitleProvider>()); - SubtitleManager.AddParts(GetExports<ISubtitleProvider>()); + Resolve<IChannelManager>().AddParts(GetExports<IChannel>()); - ChannelManager.AddParts(GetExports<IChannel>()); + Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); - MediaSourceManager.AddParts(GetExports<IMediaSourceProvider>()); + Resolve<INotificationManager>().AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>()); + } - NotificationManager.AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>()); - UserManager.AddParts(GetExports<IAuthenticationProvider>()); + /// <summary> + /// Discovers the types. + /// </summary> + protected void DiscoverTypes() + { + Logger.LogInformation("Loading assemblies"); - IsoManager.AddParts(GetExports<IIsoMounter>()); + _allConcreteTypes = GetTypes(GetComposablePartAssemblies()).ToArray(); } - private IPlugin LoadPlugin(IPlugin plugin) + private IEnumerable<Type> GetTypes(IEnumerable<Assembly> assemblies) { - try + foreach (var ass in assemblies) { - if (plugin is IPluginAssembly assemblyPlugin) + Type[] exportedTypes; + try { - 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); - } + exportedTypes = ass.GetExportedTypes(); } - - if (plugin is IHasPluginConfiguration hasPluginConfiguration) + catch (FileNotFoundException ex) { - hasPluginConfiguration.SetStartupInfo(s => Directory.CreateDirectory(s)); + 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; } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error loading plugin {pluginName}", plugin.GetType().FullName); - return null; - } - - return plugin; - } - - /// <summary> - /// Discovers the types. - /// </summary> - protected void DiscoverTypes() - { - Logger.LogInformation("Loading assemblies"); - AllConcreteTypes = GetComposablePartAssemblies() - .SelectMany(x => x.ExportedTypes) - .Where(type => + foreach (Type type in exportedTypes) { - return type.IsClass && !type.IsAbstract && !type.IsInterface && !type.IsGenericType; - }) - .ToArray(); + if (type.IsClass && !type.IsAbstract && !type.IsInterface && !type.IsGenericType) + { + yield return type; + } + } + } } - private CertificateInfo CertificateInfo { get; set; } - protected X509Certificate Certificate { get; private set; } - private IEnumerable<string> GetUrlPrefixes() { var hosts = new[] { "+" }; @@ -1167,10 +872,10 @@ namespace Emby.Server.Implementations { var prefixes = new List<string> { - "http://"+i+":" + HttpPort + "/" + "http://" + i + ":" + HttpPort + "/" }; - if (CertificateInfo != null) + if (Certificate != null) { prefixes.Add("https://" + i + ":" + HttpsPort + "/"); } @@ -1179,88 +884,6 @@ namespace Emby.Server.Implementations }); } - protected abstract IHttpListener CreateHttpListener(); - - /// <summary> - /// Starts the server. - /// </summary> - private void StartServer() - { - try - { - ((HttpListenerHost)HttpServer).StartServer(GetUrlPrefixes().ToArray(), CreateHttpListener()); - return; - } - catch (Exception ex) - { - var msg = string.Equals(ex.GetType().Name, "SocketException", StringComparison.OrdinalIgnoreCase) - ? "The http server is unable to start due to a Socket error. This can occasionally happen when the operating system takes longer than usual to release the IP bindings from the previous session. This can take up to five minutes. Please try waiting or rebooting the system." - : "Error starting Http Server"; - - Logger.LogError(ex, msg); - - if (HttpPort == ServerConfiguration.DefaultHttpPort) - { - throw; - } - } - - HttpPort = ServerConfiguration.DefaultHttpPort; - - try - { - ((HttpListenerHost)HttpServer).StartServer(GetUrlPrefixes().ToArray(), CreateHttpListener()); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error starting http server"); - - throw; - } - } - - private CertificateInfo GetCertificateInfo(bool generateCertificate) - { - if (!string.IsNullOrWhiteSpace(ServerConfigurationManager.Configuration.CertificatePath)) - { - // Custom cert - return new CertificateInfo - { - Path = ServerConfigurationManager.Configuration.CertificatePath, - Password = ServerConfigurationManager.Configuration.CertificatePassword - }; - } - - // Generate self-signed cert - var certHost = GetHostnameFromExternalDns(ServerConfigurationManager.Configuration.WanDdns); - var certPath = Path.Combine(ServerConfigurationManager.ApplicationPaths.ProgramDataPath, "ssl", "cert_" + (certHost + "2").GetMD5().ToString("N") + ".pfx"); - var password = "embycert"; - - //if (generateCertificate) - //{ - // if (!File.Exists(certPath)) - // { - // FileSystemManager.CreateDirectory(FileSystemManager.GetDirectoryName(certPath)); - - // try - // { - // CertificateGenerator.CreateSelfSignCertificatePfx(certPath, certHost, password, Logger); - // } - // catch (Exception ex) - // { - // Logger.LogError(ex, "Error creating ssl cert"); - // return null; - // } - // } - //} - - return new CertificateInfo - { - Path = certPath, - Password = password - }; - } - /// <summary> /// Called when [configuration updated]. /// </summary> @@ -1269,34 +892,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 == null ? null : CertificateInfo.Path; - var newCertInfo = GetCertificateInfo(false); - var newCertPath = newCertInfo == null ? null : newCertInfo.Path; - - if (!string.Equals(currentCertPath, newCertPath, StringComparison.OrdinalIgnoreCase)) + if (ValidateSslCertificate(networkConfiguration)) { requiresRestart = true; } @@ -1310,7 +930,34 @@ namespace Emby.Server.Implementations } /// <summary> - /// Notifies that the kernel that a change has been made that requires a restart + /// 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(CertificatePath, 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() { @@ -1347,7 +994,7 @@ namespace Emby.Server.Implementations { try { - await SessionManager.SendServerRestartNotification(CancellationToken.None).ConfigureAwait(false); + await _sessionManager.SendServerRestartNotification(CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { @@ -1368,21 +1015,11 @@ 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.TopDirectoryOnly)) - { - Logger.LogInformation("Loading assembly {Path}", file); - yield return Assembly.LoadFrom(file); - } + yield return p; } - // Include composable parts in the Api assembly - yield return typeof(ApiEntryPoint).Assembly; - - // Include composable parts in the Dashboard assembly - yield return typeof(DashboardService).Assembly; - // Include composable parts in the Model assembly yield return typeof(SystemInfo).Assembly; @@ -1416,6 +1053,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; @@ -1427,277 +1067,155 @@ namespace Emby.Server.Implementations /// <summary> /// Gets the system status. /// </summary> + /// <param name="request">Where this request originated.</param> /// <returns>SystemInfo.</returns> - public async Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken) + public SystemInfo GetSystemInfo(HttpRequest request) { - var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false); - var wanAddress = await GetWanApiUrl(cancellationToken).ConfigureAwait(false); - return new SystemInfo { HasPendingRestart = HasPendingRestart, IsShuttingDown = IsShuttingDown, - Version = ApplicationVersion, - ProductName = ApplicationProductName, + Version = ApplicationVersionString, WebSocketPortNumber = HttpPort, - CompletedInstallations = InstallationManager.CompletedInstallations.ToArray(), + CompletedInstallations = Resolve<IInstallationManager>().CompletedInstallations.ToArray(), Id = SystemId, ProgramDataPath = ApplicationPaths.ProgramDataPath, + WebPath = ApplicationPaths.WebPath, LogPath = ApplicationPaths.LogDirectoryPath, ItemsByNamePath = ApplicationPaths.InternalMetadataPath, InternalMetadataPath = ApplicationPaths.InternalMetadataPath, CachePath = ApplicationPaths.CachePath, - HttpServerPortNumber = HttpPort, - SupportsHttps = SupportsHttps, - HttpsPortNumber = HttpsPort, - OperatingSystem = EnvironmentInfo.OperatingSystem.ToString(), - OperatingSystemDisplayName = EnvironmentInfo.OperatingSystemName, + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), + OperatingSystemDisplayName = MediaBrowser.Common.System.OperatingSystem.Name, CanSelfRestart = CanSelfRestart, CanLaunchWebBrowser = CanLaunchWebBrowser, - WanAddress = wanAddress, - HasUpdateAvailable = HasUpdateAvailable, - TranscodingTempPath = ApplicationPaths.TranscodingTempPath, + TranscodingTempPath = ConfigurationManager.GetTranscodePath(), ServerName = FriendlyName, - LocalAddress = localAddress, + LocalAddress = GetSmartApiUrl(request), SupportsLibraryMonitor = true, - EncoderLocationType = MediaEncoder.EncoderLocationType, - SystemArchitecture = EnvironmentInfo.SystemArchitecture, - SystemUpdateLevel = SystemUpdateLevel, - PackageName = StartupOptions.PackageName + SystemArchitecture = RuntimeInformation.OSArchitecture, + PackageName = _startupOptions.PackageName }; } - public WakeOnLanInfo[] GetWakeOnLanInfo() - { - return NetworkManager.GetMacAddresses() - .Select(i => new WakeOnLanInfo - { - MacAddress = i - }) - .ToArray(); - } + public IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo() + => NetManager.GetMacAddresses() + .Select(i => new WakeOnLanInfo(i)) + .ToList(); - public async Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken) + public PublicSystemInfo GetPublicSystemInfo(HttpRequest request) { - var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false); - var wanAddress = await GetWanApiUrl(cancellationToken).ConfigureAwait(false); return new PublicSystemInfo { - Version = ApplicationVersion, + Version = ApplicationVersionString, + ProductName = ApplicationProductName, Id = SystemId, - OperatingSystem = EnvironmentInfo.OperatingSystem.ToString(), - WanAddress = wanAddress, + OperatingSystem = MediaBrowser.Common.System.OperatingSystem.Id.ToString(), ServerName = FriendlyName, - LocalAddress = localAddress + LocalAddress = GetSmartApiUrl(request), + StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted }; } - public bool EnableHttps => SupportsHttps && ServerConfigurationManager.Configuration.EnableHttps; - - public bool SupportsHttps => Certificate != null || ServerConfigurationManager.Configuration.IsBehindProxy; - - public async Task<string> GetLocalApiUrl(CancellationToken cancellationToken) + /// <inheritdoc/> + 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); - - foreach (var address in addresses) - { - return GetLocalApiUrl(address); - } - - return null; + // 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); } - public async Task<string> GetWanApiUrl(CancellationToken cancellationToken) + /// <inheritdoc/> + public string GetSmartApiUrl(HttpRequest request, int? port = null) { - const string url = "http://ipv4.icanhazip.com"; - try + // Return the host in the HTTP request as the API url + if (ConfigurationManager.GetNetworkConfiguration().EnablePublishedServerUriByRequest) { - using (var response = await HttpClient.Get(new HttpRequestOptions - { - Url = url, - LogErrorResponseBody = false, - LogErrors = false, - LogRequest = false, - TimeoutMs = 10000, - BufferContent = false, - CancellationToken = cancellationToken - })) + int? requestPort = request.Host.Port; + if ((requestPort == 80 && string.Equals(request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) || (requestPort == 443 && string.Equals(request.Scheme, "https", StringComparison.OrdinalIgnoreCase))) { - return GetLocalApiUrl(response.ReadToEnd().Trim()); + requestPort = -1; } + + return GetLocalApiUrl(request.Host.Host, request.Scheme, requestPort); } - catch (Exception ex) + + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - Logger.LogError(ex, "Error getting WAN Ip address information"); + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - return null; - } - public string GetLocalApiUrl(IpAddressInfo ipAddress) - { - if (ipAddress.AddressFamily == IpAddressFamily.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)) { - return GetLocalApiUrl("[" + ipAddress.Address + "]"); + return smart.Trim('/'); } - return GetLocalApiUrl(ipAddress.Address); - } - - public string GetLocalApiUrl(string host) - { - return string.Format("http://{0}:{1}", - host, - HttpPort.ToString(CultureInfo.InvariantCulture)); - } - - public Task<List<IpAddressInfo>> GetLocalIpAddresses(CancellationToken cancellationToken) - { - return GetLocalIpAddressesInternal(true, 0, cancellationToken); + return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port); } - private async Task<List<IpAddressInfo>> GetLocalIpAddressesInternal(bool allowLoopback, int limit, CancellationToken cancellationToken) + /// <inheritdoc/> + public string GetSmartApiUrl(string hostname, int? port = null) { - var addresses = ServerConfigurationManager - .Configuration - .LocalNetworkAddresses - .Select(NormalizeConfiguredLocalAddress) - .Where(i => i != null) - .ToList(); - - if (addresses.Count == 0) + // Published server ends with a / + if (!string.IsNullOrEmpty(PublishedServerUrl)) { - addresses.AddRange(NetworkManager.GetLocalIpAddresses()); + // Published server ends with a '/', so we need to remove it. + return PublishedServerUrl.Trim('/'); } - var resultList = new List<IpAddressInfo>(); + 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(IpAddressInfo.Loopback) || address.Equals(IpAddressInfo.IPv6Loopback)) - { - continue; - } - } - - var valid = await IsIpAddressValidAsync(address, cancellationToken).ConfigureAwait(false); - if (valid) - { - resultList.Add(address); - - if (limit > 0 && resultList.Count >= limit) - { - return resultList; - } - } + return smart.Trim('/'); } - return resultList; + return GetLocalApiUrl(smart.Trim('/'), null, port); } - private IpAddressInfo NormalizeConfiguredLocalAddress(string address) + /// <inheritdoc/> + public string GetLoopbackHttpApiUrl() { - var index = address.Trim('/').IndexOf('/'); - - if (index != -1) + if (NetManager.IsIP6Enabled) { - address = address.Substring(index + 1); + return GetLocalApiUrl("::1", Uri.UriSchemeHttp, HttpPort); } - if (NetworkManager.TryParseIpAddress(address.Trim('/'), out IpAddressInfo 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> IsIpAddressValidAsync(IpAddressInfo address, CancellationToken cancellationToken) + /// <inheritdoc/> + public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null) { - if (address.Equals(IpAddressInfo.Loopback) || - address.Equals(IpAddressInfo.IPv6Loopback)) - { - return true; - } - - var apiUrl = GetLocalApiUrl(address); - apiUrl += "/system/ping"; - - if (_validAddressResults.TryGetValue(apiUrl, out var cachedResult)) - { - return cachedResult; - } - - var logPing = false; - -#if DEBUG - logPing = true; -#endif - - try - { - using (var response = await HttpClient.SendAsync(new HttpRequestOptions - { - Url = apiUrl, - LogErrorResponseBody = false, - LogErrors = logPing, - LogRequest = logPing, - TimeoutMs = 30000, - BufferContent = false, - - CancellationToken = cancellationToken - - }, "POST").ConfigureAwait(false)) - { - using (var reader = new StreamReader(response.Content)) - { - var result = reader.ReadToEnd(); - 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) - { - Logger.LogDebug(ex, "Ping test result to {0}. Success: {1}", apiUrl, false); - - _validAddressResults.AddOrUpdate(apiUrl, false, (k, v) => false); - return false; - } + // 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 + { + 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) - ? Environment.MachineName - : ServerConfigurationManager.Configuration.ServerName; - - public int HttpPort { get; private set; } - - public int HttpsPort { get; private set; } - - /// <summary> - /// Shuts down. - /// </summary> + /// <inheritdoc /> public async Task Shutdown() { if (IsShuttingDown) @@ -1709,7 +1227,7 @@ namespace Emby.Server.Implementations try { - await SessionManager.SendServerShutdownNotification(CancellationToken.None).ConfigureAwait(false); + await _sessionManager.SendServerShutdownNotification(CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { @@ -1721,124 +1239,25 @@ namespace Emby.Server.Implementations protected abstract void ShutdownInternal(); - public event EventHandler HasUpdateAvailableChanged; - - private bool _hasUpdateAvailable; - public bool HasUpdateAvailable - { - get => _hasUpdateAvailable; - set - { - var fireEvent = value && !_hasUpdateAvailable; - - _hasUpdateAvailable = value; - - if (fireEvent) - { - HasUpdateAvailableChanged?.Invoke(this, EventArgs.Empty); - } - } - } - - /// <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(); - } - - /// <summary> - /// This returns localhost in the case of no external dns, and the hostname if the - /// dns is prefixed with a valid Uri prefix. - /// </summary> - /// <param name="externalDns">The external dns prefix to get the hostname of.</param> - /// <returns>The hostname in <paramref name="externalDns"/></returns> - private static string GetHostnameFromExternalDns(string externalDns) + public IEnumerable<Assembly> GetApiPluginAssemblies() { - if (string.IsNullOrEmpty(externalDns)) - { - return "localhost"; - } - - try - { - return new Uri(externalDns).Host; - } - catch - { - return externalDns; - } - } - - public virtual void LaunchUrl(string url) - { - if (!CanLaunchWebBrowser) - { - throw new NotSupportedException(); - } - - var process = ProcessFactory.Create(new ProcessOptions - { - FileName = url, - //EnableRaisingEvents = true, - UseShellExecute = true, - ErrorDialog = false - }); - - process.Exited += ProcessExited; + var assemblies = _allConcreteTypes + .Where(i => typeof(ControllerBase).IsAssignableFrom(i)) + .Select(i => i.Assembly) + .Distinct(); - try - { - process.Start(); - } - catch (Exception ex) + foreach (var assembly in assemblies) { - Logger.LogError(ex, "Error launching url: {url}", url); - throw; + Logger.LogDebug("Found API endpoints in plugin {Name}", assembly.FullName); + yield return assembly; } } - private static void ProcessExited(object sender, EventArgs e) - { - ((IProcess)sender).Dispose(); - } - - public virtual void EnableLoopback(string appName) - { - } - - /// <summary> - /// Called when [application updated]. - /// </summary> - /// <param name="package">The package.</param> - protected void OnApplicationUpdated(PackageVersionInfo package) - { - Logger.LogInformation("Application has been updated to version {0}", package.versionStr); - - ApplicationUpdated?.Invoke(this, new GenericEventArgs<PackageVersionInfo> - { - Argument = package - }); - - NotifyPendingRestart(); - } - - private bool _disposed; - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> + /// <inheritdoc /> public void Dispose() { - if (!_disposed) - { - _disposed = true; - - Dispose(true); - } + Dispose(true); + GC.SuppressFinalize(this); } /// <summary> @@ -1847,14 +1266,19 @@ namespace Emby.Server.Implementations /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> protected virtual void Dispose(bool dispose) { + if (_disposed) + { + return; + } + if (dispose) { var type = GetType(); Logger.LogInformation("Disposing {Type}", type.Name); - var parts = DisposableParts.Distinct().Where(i => i.GetType() != type).ToList(); - DisposableParts.Clear(); + var parts = _disposableParts.Distinct().Where(i => i.GetType() != type).ToList(); + _disposableParts.Clear(); foreach (var part in parts) { @@ -1870,12 +1294,8 @@ namespace Emby.Server.Implementations } } } - } - } - internal class CertificateInfo - { - public string Path { get; set; } - public string Password { get; set; } + _disposed = true; + } } } diff --git a/Emby.Server.Implementations/Archiving/ZipClient.cs b/Emby.Server.Implementations/Archiving/ZipClient.cs index 6b0fd2dc6..9e1d550eb 100644 --- a/Emby.Server.Implementations/Archiving/ZipClient.cs +++ b/Emby.Server.Implementations/Archiving/ZipClient.cs @@ -10,15 +10,10 @@ using SharpCompress.Readers.Zip; namespace Emby.Server.Implementations.Archiving { /// <summary> - /// Class DotNetZipClient + /// Class DotNetZipClient. /// </summary> public class ZipClient : IZipClient { - public ZipClient() - { - - } - /// <summary> /// Extracts all. /// </summary> @@ -27,10 +22,8 @@ namespace Emby.Server.Implementations.Archiving /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> public void ExtractAll(string sourceFile, string targetPath, bool overwriteExistingFiles) { - using (var fileStream = File.OpenRead(sourceFile)) - { - ExtractAll(fileStream, targetPath, overwriteExistingFiles); - } + using var fileStream = File.OpenRead(sourceFile); + ExtractAll(fileStream, targetPath, overwriteExistingFiles); } /// <summary> @@ -41,67 +34,64 @@ namespace Emby.Server.Implementations.Archiving /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> public void ExtractAll(Stream source, string targetPath, bool overwriteExistingFiles) { - using (var reader = ReaderFactory.Open(source)) + using var reader = ReaderFactory.Open(source); + var options = new ExtractionOptions { - var options = new ExtractionOptions(); - options.ExtractFullPath = true; - - if (overwriteExistingFiles) - { - options.Overwrite = true; - } + ExtractFullPath = true + }; - reader.WriteAllToDirectory(targetPath, options); + if (overwriteExistingFiles) + { + options.Overwrite = true; } + + Directory.CreateDirectory(targetPath); + reader.WriteAllToDirectory(targetPath, options); } + /// <inheritdoc /> public void ExtractAllFromZip(Stream source, string targetPath, bool overwriteExistingFiles) { - using (var reader = ZipReader.Open(source)) + using var reader = ZipReader.Open(source); + var options = new ExtractionOptions { - var options = new ExtractionOptions(); - options.ExtractFullPath = true; - - if (overwriteExistingFiles) - { - options.Overwrite = true; - } + ExtractFullPath = true, + Overwrite = overwriteExistingFiles + }; - reader.WriteAllToDirectory(targetPath, options); - } + Directory.CreateDirectory(targetPath); + reader.WriteAllToDirectory(targetPath, options); } + /// <inheritdoc /> public void ExtractAllFromGz(Stream source, string targetPath, bool overwriteExistingFiles) { - using (var reader = GZipReader.Open(source)) + using var reader = GZipReader.Open(source); + var options = new ExtractionOptions { - var options = new ExtractionOptions(); - options.ExtractFullPath = true; - - if (overwriteExistingFiles) - { - options.Overwrite = true; - } + ExtractFullPath = true, + Overwrite = overwriteExistingFiles + }; - reader.WriteAllToDirectory(targetPath, options); - } + Directory.CreateDirectory(targetPath); + reader.WriteAllToDirectory(targetPath, options); } + /// <inheritdoc /> public void ExtractFirstFileFromGz(Stream source, string targetPath, string defaultFileName) { - using (var reader = GZipReader.Open(source)) + using var reader = GZipReader.Open(source); + if (reader.MoveToNextEntry()) { - if (reader.MoveToNextEntry()) + var entry = reader.Entry; + + var filename = entry.Key; + if (string.IsNullOrWhiteSpace(filename)) { - var entry = reader.Entry; - - var filename = entry.Key; - if (string.IsNullOrWhiteSpace(filename)) - { - filename = defaultFileName; - } - reader.WriteEntryToFile(Path.Combine(targetPath, filename)); + filename = defaultFileName; } + + reader.WriteEntryToFile(Path.Combine(targetPath, filename)); } } @@ -113,10 +103,8 @@ namespace Emby.Server.Implementations.Archiving /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> public void ExtractAllFrom7z(string sourceFile, string targetPath, bool overwriteExistingFiles) { - using (var fileStream = File.OpenRead(sourceFile)) - { - ExtractAllFrom7z(fileStream, targetPath, overwriteExistingFiles); - } + using var fileStream = File.OpenRead(sourceFile); + ExtractAllFrom7z(fileStream, targetPath, overwriteExistingFiles); } /// <summary> @@ -127,24 +115,18 @@ namespace Emby.Server.Implementations.Archiving /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> public void ExtractAllFrom7z(Stream source, string targetPath, bool overwriteExistingFiles) { - using (var archive = SevenZipArchive.Open(source)) + using var archive = SevenZipArchive.Open(source); + using var reader = archive.ExtractAllEntries(); + var options = new ExtractionOptions { - using (var reader = archive.ExtractAllEntries()) - { - var options = new ExtractionOptions(); - options.ExtractFullPath = true; + ExtractFullPath = true, + Overwrite = overwriteExistingFiles + }; - if (overwriteExistingFiles) - { - options.Overwrite = true; - } - - reader.WriteAllToDirectory(targetPath, options); - } - } + Directory.CreateDirectory(targetPath); + reader.WriteAllToDirectory(targetPath, options); } - /// <summary> /// Extracts all from tar. /// </summary> @@ -153,10 +135,8 @@ namespace Emby.Server.Implementations.Archiving /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> public void ExtractAllFromTar(string sourceFile, string targetPath, bool overwriteExistingFiles) { - using (var fileStream = File.OpenRead(sourceFile)) - { - ExtractAllFromTar(fileStream, targetPath, overwriteExistingFiles); - } + using var fileStream = File.OpenRead(sourceFile); + ExtractAllFromTar(fileStream, targetPath, overwriteExistingFiles); } /// <summary> @@ -167,21 +147,16 @@ namespace Emby.Server.Implementations.Archiving /// <param name="overwriteExistingFiles">if set to <c>true</c> [overwrite existing files].</param> public void ExtractAllFromTar(Stream source, string targetPath, bool overwriteExistingFiles) { - using (var archive = TarArchive.Open(source)) + using var archive = TarArchive.Open(source); + using var reader = archive.ExtractAllEntries(); + var options = new ExtractionOptions { - using (var reader = archive.ExtractAllEntries()) - { - var options = new ExtractionOptions(); - options.ExtractFullPath = true; - - if (overwriteExistingFiles) - { - options.Overwrite = true; - } + ExtractFullPath = true, + Overwrite = overwriteExistingFiles + }; - reader.WriteAllToDirectory(targetPath, options); - } - } + Directory.CreateDirectory(targetPath); + reader.WriteAllToDirectory(targetPath, options); } } } diff --git a/Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs b/Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs index b27f84848..7ae26bd8b 100644 --- a/Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs +++ b/Emby.Server.Implementations/Branding/BrandingConfigurationFactory.cs @@ -4,8 +4,12 @@ using MediaBrowser.Model.Branding; namespace Emby.Server.Implementations.Branding { + /// <summary> + /// A configuration factory for <see cref="BrandingOptions"/>. + /// </summary> public class BrandingConfigurationFactory : IConfigurationFactory { + /// <inheritdoc /> public IEnumerable<ConfigurationStore> GetConfigurations() { return new[] diff --git a/Emby.Server.Implementations/Browser/BrowserLauncher.cs b/Emby.Server.Implementations/Browser/BrowserLauncher.cs deleted file mode 100644 index 718129ef0..000000000 --- a/Emby.Server.Implementations/Browser/BrowserLauncher.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using MediaBrowser.Controller; - -namespace Emby.Server.Implementations.Browser -{ - /// <summary> - /// Class BrowserLauncher - /// </summary> - public static class BrowserLauncher - { - /// <summary> - /// Opens the dashboard page. - /// </summary> - /// <param name="page">The page.</param> - /// <param name="appHost">The app host.</param> - private static void OpenDashboardPage(string page, IServerApplicationHost appHost) - { - var url = appHost.GetLocalApiUrl("localhost") + "/web/" + page; - - OpenUrl(appHost, url); - } - - /// <summary> - /// Opens the web client. - /// </summary> - /// <param name="appHost">The app host.</param> - public static void OpenWebApp(IServerApplicationHost appHost) - { - OpenDashboardPage("index.html", appHost); - } - - /// <summary> - /// Opens the URL. - /// </summary> - /// <param name="url">The URL.</param> - private static void OpenUrl(IServerApplicationHost appHost, string url) - { - try - { - appHost.LaunchUrl(url); - } - catch (NotSupportedException) - { - - } - catch (Exception) - { - } - } - } -} diff --git a/Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs b/Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs index c10f00f9b..3e149cc82 100644 --- a/Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs +++ b/Emby.Server.Implementations/Channels/ChannelDynamicMediaSourceProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Channels; @@ -9,25 +10,31 @@ using MediaBrowser.Model.Dto; namespace Emby.Server.Implementations.Channels { + /// <summary> + /// A media source provider for channels. + /// </summary> public class ChannelDynamicMediaSourceProvider : IMediaSourceProvider { private readonly ChannelManager _channelManager; + /// <summary> + /// Initializes a new instance of the <see cref="ChannelDynamicMediaSourceProvider"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> public ChannelDynamicMediaSourceProvider(IChannelManager channelManager) { _channelManager = (ChannelManager)channelManager; } + /// <inheritdoc /> public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken) { - if (item.SourceType == SourceType.Channel) - { - return _channelManager.GetDynamicMediaSources(item, cancellationToken); - } - - return Task.FromResult<IEnumerable<MediaSourceInfo>>(new List<MediaSourceInfo>()); + return item.SourceType == SourceType.Channel + ? _channelManager.GetDynamicMediaSources(item, cancellationToken) + : Task.FromResult(Enumerable.Empty<MediaSourceInfo>()); } + /// <inheritdoc /> public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { throw new NotImplementedException(); diff --git a/Emby.Server.Implementations/Channels/ChannelImageProvider.cs b/Emby.Server.Implementations/Channels/ChannelImageProvider.cs index bafa68818..25cbfcf14 100644 --- a/Emby.Server.Implementations/Channels/ChannelImageProvider.cs +++ b/Emby.Server.Implementations/Channels/ChannelImageProvider.cs @@ -9,20 +9,32 @@ using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Channels { + /// <summary> + /// An image provider for channels. + /// </summary> public class ChannelImageProvider : IDynamicImageProvider, IHasItemChangeMonitor { private readonly IChannelManager _channelManager; + /// <summary> + /// Initializes a new instance of the <see cref="ChannelImageProvider"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> public ChannelImageProvider(IChannelManager channelManager) { _channelManager = channelManager; } + /// <inheritdoc /> + public string Name => "Channel Image Provider"; + + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return GetChannel(item).GetSupportedChannelImages(); } + /// <inheritdoc /> public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken) { var channel = GetChannel(item); @@ -30,8 +42,7 @@ namespace Emby.Server.Implementations.Channels return channel.GetChannelImage(type, cancellationToken); } - public string Name => "Channel Image Provider"; - + /// <inheritdoc /> public bool Supports(BaseItem item) { return item is Channel; @@ -44,6 +55,7 @@ namespace Emby.Server.Implementations.Channels return ((ChannelManager)_channelManager).GetChannelProvider(channel); } + /// <inheritdoc /> public bool HasChanged(BaseItem item, IDirectoryService directoryService) { return GetSupportedImages(item).Any(i => !item.HasImage(i)); diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 949b89226..09aee602a 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -1,100 +1,120 @@ +#nullable disable + 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 Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; 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.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Channels; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; 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; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; +using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; +using Season = MediaBrowser.Controller.Entities.TV.Season; +using Series = MediaBrowser.Controller.Entities.TV.Series; namespace Emby.Server.Implementations.Channels { + /// <summary> + /// The LiveTV channel manager. + /// </summary> public class ChannelManager : IChannelManager { - internal IChannel[] Channels { get; private set; } - private readonly IUserManager _userManager; private readonly IUserDataManager _userDataManager; private readonly IDtoService _dtoService; private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + private readonly ILogger<ChannelManager> _logger; private readonly IServerConfigurationManager _config; private readonly IFileSystem _fileSystem; - private readonly IJsonSerializer _jsonSerializer; - private readonly IHttpClient _httpClient; private readonly IProviderManager _providerManager; - - private readonly ILocalizationManager _localization; - + 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. + /// </summary> + /// <param name="userManager">The user manager.</param> + /// <param name="dtoService">The dto service.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="config">The server configuration manager.</param> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="userDataManager">The user data manager.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="memoryCache">The memory cache.</param> public ChannelManager( IUserManager userManager, IDtoService dtoService, ILibraryManager libraryManager, - ILoggerFactory loggerFactory, + ILogger<ChannelManager> logger, IServerConfigurationManager config, IFileSystem fileSystem, IUserDataManager userDataManager, - IJsonSerializer jsonSerializer, - ILocalizationManager localization, - IHttpClient httpClient, - IProviderManager providerManager) + IProviderManager providerManager, + IMemoryCache memoryCache) { _userManager = userManager; _dtoService = dtoService; _libraryManager = libraryManager; - _logger = loggerFactory.CreateLogger(nameof(ChannelManager)); + _logger = logger; _config = config; _fileSystem = fileSystem; _userDataManager = userDataManager; - _jsonSerializer = jsonSerializer; - _localization = localization; - _httpClient = httpClient; _providerManager = providerManager; + _memoryCache = memoryCache; } + internal IChannel[] Channels { get; private set; } + private static TimeSpan CacheLength => TimeSpan.FromHours(3); + /// <inheritdoc /> public void AddParts(IEnumerable<IChannel> channels) { Channels = channels.ToArray(); } + /// <inheritdoc /> public bool EnableMediaSourceDisplay(BaseItem item) { var internalChannel = _libraryManager.GetItemById(item.ChannelId); var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); - return !(channel is IDisableMediaSourceDisplay); + return channel is not IDisableMediaSourceDisplay; } + /// <inheritdoc /> public bool CanDelete(BaseItem item) { var internalChannel = _libraryManager.GetItemById(item.ChannelId); var channel = Channels.FirstOrDefault(i => GetInternalChannelId(i.Name).Equals(internalChannel.Id)); - var supportsDelete = channel as ISupportsDelete; - return supportsDelete != null && supportsDelete.CanDelete(item); + return channel is ISupportsDelete supportsDelete && supportsDelete.CanDelete(item); } + /// <inheritdoc /> public bool EnableMediaProbe(BaseItem item) { var internalChannel = _libraryManager.GetItemById(item.ChannelId); @@ -103,6 +123,7 @@ namespace Emby.Server.Implementations.Channels return channel is ISupportsMediaProbe; } + /// <inheritdoc /> public Task DeleteItem(BaseItem item) { var internalChannel = _libraryManager.GetItemById(item.ChannelId); @@ -129,11 +150,16 @@ namespace Emby.Server.Implementations.Channels .OrderBy(i => i.Name); } + /// <summary> + /// Get the installed channel IDs. + /// </summary> + /// <returns>An <see cref="IEnumerable{T}"/> containing installed channel IDs.</returns> public IEnumerable<Guid> GetInstalledChannelIds() { return GetAllChannels().Select(i => GetInternalChannelId(i.Name)); } + /// <inheritdoc /> public QueryResult<Channel> GetChannelsInternal(ChannelQuery query) { var user = query.UserId.Equals(Guid.Empty) @@ -152,15 +178,13 @@ namespace Emby.Server.Implementations.Channels { try { - var hasAttributes = GetChannelProvider(i) as IHasFolderAttributes; - - return (hasAttributes != null && hasAttributes.Attributes.Contains("Recordings", StringComparer.OrdinalIgnoreCase)) == val; + return (GetChannelProvider(i) is IHasFolderAttributes hasAttributes + && hasAttributes.Attributes.Contains("Recordings", StringComparer.OrdinalIgnoreCase)) == val; } catch { return false; } - }).ToList(); } @@ -177,7 +201,6 @@ namespace Emby.Server.Implementations.Channels { return false; } - }).ToList(); } @@ -194,9 +217,9 @@ namespace Emby.Server.Implementations.Channels { return false; } - }).ToList(); } + if (query.IsFavorite.HasValue) { var val = query.IsFavorite.Value; @@ -215,46 +238,41 @@ namespace Emby.Server.Implementations.Channels try { - return GetChannelProvider(i).IsEnabledFor(user.Id.ToString("N")); + return GetChannelProvider(i).IsEnabledFor(user.Id.ToString("N", CultureInfo.InvariantCulture)); } catch { return false; } - }).ToList(); } 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) { - var task = RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None); - Task.WaitAll(task); + RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult(); } } return new QueryResult<Channel> { - Items = returnItems, + Items = all, TotalRecordCount = totalCount }; } + /// <inheritdoc /> public QueryResult<BaseItemDto> GetChannels(ChannelQuery query) { var user = query.UserId.Equals(Guid.Empty) @@ -263,11 +281,9 @@ namespace Emby.Server.Implementations.Channels var internalResult = GetChannelsInternal(query); - var dtoOptions = new DtoOptions() - { - }; + var dtoOptions = new DtoOptions(); - //TODO Fix The co-variant conversion (internalResult.Items) between Folder[] and BaseItem[], this can generate runtime issues. + // TODO Fix The co-variant conversion (internalResult.Items) between Folder[] and BaseItem[], this can generate runtime issues. var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, dtoOptions, user); var result = new QueryResult<BaseItemDto> @@ -279,6 +295,12 @@ namespace Emby.Server.Implementations.Channels return result; } + /// <summary> + /// Refreshes the associated channels. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> + /// <returns>The completed task.</returns> public async Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken) { var allChannelsList = GetAllChannels().ToList(); @@ -303,9 +325,7 @@ namespace Emby.Server.Implementations.Channels } numComplete++; - double percent = numComplete; - percent /= allChannelsList.Count; - + double percent = (double)numComplete / allChannelsList.Count; progress.Report(100 * percent); } @@ -314,31 +334,26 @@ namespace Emby.Server.Implementations.Channels private Channel GetChannelEntity(IChannel channel) { - var item = GetChannel(GetInternalChannelId(channel.Name)); - - if (item == null) - { - item = GetChannel(channel, CancellationToken.None).Result; - } - - return item; + 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"); @@ -350,16 +365,18 @@ namespace Emby.Server.Implementations.Channels } catch { - } + return; } 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 /> public IEnumerable<MediaSourceInfo> GetStaticMediaSources(BaseItem item, CancellationToken cancellationToken) { IEnumerable<MediaSourceInfo> results = GetSavedMediaSources(item); @@ -369,16 +386,20 @@ namespace Emby.Server.Implementations.Channels .ToList(); } + /// <summary> + /// Gets the dynamic media sources based on the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> + /// <returns>The task representing the operation to get the media sources.</returns> public async Task<IEnumerable<MediaSourceInfo>> GetDynamicMediaSources(BaseItem item, CancellationToken cancellationToken) { var channel = GetChannel(item.ChannelId); var channelPlugin = GetChannelProvider(channel); - var requiresCallback = channelPlugin as IRequiresMediaInfoCallback; - IEnumerable<MediaSourceInfo> results; - if (requiresCallback != null) + if (channelPlugin is IRequiresMediaInfoCallback requiresCallback) { results = await GetChannelItemMediaSourcesInternal(requiresCallback, item.ExternalId, cancellationToken) .ConfigureAwait(false); @@ -393,32 +414,24 @@ namespace Emby.Server.Implementations.Channels .ToList(); } - private readonly ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>> _channelItemMediaInfo = - new ConcurrentDictionary<string, Tuple<DateTime, List<MediaSourceInfo>>>(); - private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken) { - if (_channelItemMediaInfo.TryGetValue(id, out Tuple<DateTime, List<MediaSourceInfo>> cachedInfo)) + if (_memoryCache.TryGetValue(id, out List<MediaSourceInfo> cachedInfo)) { - if ((DateTime.UtcNow - cachedInfo.Item1).TotalMinutes < 5) - { - return cachedInfo.Item2; - } + return cachedInfo; } var mediaInfo = await channel.GetChannelItemMediaInfo(id, cancellationToken) .ConfigureAwait(false); var list = mediaInfo.ToList(); - - var item2 = new Tuple<DateTime, List<MediaSourceInfo>>(DateTime.UtcNow, list); - _channelItemMediaInfo.AddOrUpdate(id, item2, (key, oldValue) => item2); + _memoryCache.Set(id, list, DateTimeOffset.UtcNow.AddMinutes(5)); return list; } private static MediaSourceInfo NormalizeMediaSource(BaseItem item, MediaSourceInfo info) { - info.RunTimeTicks = info.RunTimeTicks ?? item.RunTimeTicks; + info.RunTimeTicks ??= item.RunTimeTicks; return info; } @@ -453,18 +466,21 @@ namespace Emby.Server.Implementations.Channels { isNew = true; } + item.Path = path; if (!item.ChannelId.Equals(id)) { forceUpdate = true; } + item.ChannelId = id; if (item.ParentId != parentFolderId) { forceUpdate = true; } + item.ParentId = parentFolderId; item.OfficialRating = GetOfficialRating(channelInfo.ParentalRating); @@ -481,79 +497,96 @@ namespace Emby.Server.Implementations.Channels _libraryManager.CreateItem(item, null); } - await item.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - ForceSave = !isNew && forceUpdate - }, cancellationToken); + await item.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = !isNew && forceUpdate + }, + cancellationToken).ConfigureAwait(false); return item; } private static string GetOfficialRating(ChannelParentalRating rating) { - switch (rating) - { - case ChannelParentalRating.Adult: - return "XXX"; - case ChannelParentalRating.UsR: - return "R"; - case ChannelParentalRating.UsPG13: - return "PG-13"; - case ChannelParentalRating.UsPG: - return "PG"; - default: - return null; - } + return rating switch + { + ChannelParentalRating.Adult => "XXX", + ChannelParentalRating.UsR => "R", + ChannelParentalRating.UsPG13 => "PG-13", + ChannelParentalRating.UsPG => "PG", + _ => null + }; } + /// <summary> + /// Gets a channel with the provided Guid. + /// </summary> + /// <param name="id">The Guid.</param> + /// <returns>The corresponding channel.</returns> public Channel GetChannel(Guid id) { return _libraryManager.GetItemById(id) as Channel; } + /// <inheritdoc /> public Channel GetChannel(string id) { return _libraryManager.GetItemById(id) as Channel; } + /// <inheritdoc /> public ChannelFeatures[] GetAllChannelFeatures() { - return _libraryManager.GetItemIds(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(Channel).Name }, - OrderBy = new ValueTuple<string, SortOrder>[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) } - - }).Select(i => GetChannelFeatures(i.ToString("N"))).ToArray(); + return _libraryManager.GetItemIds( + new InternalItemsQuery + { + IncludeItemTypes = new[] { nameof(Channel) }, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } + }).Select(i => GetChannelFeatures(i)).ToArray(); } - public ChannelFeatures GetChannelFeatures(string id) + /// <inheritdoc /> + 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()); } + /// <summary> + /// Checks whether the provided Guid supports external transfer. + /// </summary> + /// <param name="channelId">The Guid.</param> + /// <returns>Whether or not the provided Guid supports external transfer.</returns> public bool SupportsExternalTransfer(Guid channelId) { - //var channel = GetChannel(channelId); var channelProvider = GetChannelProvider(channelId); return channelProvider.GetChannelFeatures().SupportsContentDownloading; } - public ChannelFeatures GetChannelFeaturesDto(Channel channel, + /// <summary> + /// Gets the provided channel's supported features. + /// </summary> + /// <param name="channel">The channel.</param> + /// <param name="provider">The provider.</param> + /// <param name="features">The features.</param> + /// <returns>The supported features.</returns> + public ChannelFeatures GetChannelFeaturesDto( + Channel channel, IChannel provider, InternalChannelFeatures features) { var supportsLatest = provider is ISupportsLatestMedia; - return new ChannelFeatures + return new ChannelFeatures(channel.Name, channel.Id) { CanFilter = !features.MaxPageSize.HasValue, CanSearch = provider is ISearchableChannel, @@ -563,8 +596,6 @@ namespace Emby.Server.Implementations.Channels MediaTypes = features.MediaTypes.ToArray(), SupportsSortOrderToggle = features.SupportsSortOrderToggle, SupportsLatestMedia = supportsLatest, - Name = channel.Name, - Id = channel.Id.ToString("N"), SupportsContentDownloading = features.SupportsContentDownloading, AutoRefreshLevels = features.AutoRefreshLevels }; @@ -576,9 +607,11 @@ namespace Emby.Server.Implementations.Channels { throw new ArgumentNullException(nameof(name)); } + return _libraryManager.GetNewItemId("Channel " + name, typeof(Channel)); } + /// <inheritdoc /> public async Task<QueryResult<BaseItemDto>> GetLatestChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) { var internalResult = await GetLatestChannelItemsInternal(query, cancellationToken).ConfigureAwait(false); @@ -597,11 +630,12 @@ namespace Emby.Server.Implementations.Channels return result; } + /// <inheritdoc /> public async Task<QueryResult<BaseItem>> GetLatestChannelItemsInternal(InternalItemsQuery query, CancellationToken cancellationToken) { 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; @@ -623,22 +657,22 @@ namespace Emby.Server.Implementations.Channels query.IsFolder = false; // hack for trailers, figure out a better way later - var sortByPremiereDate = channels.Length == 1 && channels[0].GetType().Name.IndexOf("Trailer") != -1; + var sortByPremiereDate = channels.Length == 1 && channels[0].GetType().Name.Contains("Trailer", StringComparison.Ordinal); if (sortByPremiereDate) { query.OrderBy = new[] { - new ValueTuple<string, SortOrder>(ItemSortBy.PremiereDate, SortOrder.Descending), - new ValueTuple<string, SortOrder>(ItemSortBy.ProductionYear, SortOrder.Descending), - new ValueTuple<string, SortOrder>(ItemSortBy.DateCreated, SortOrder.Descending) + (ItemSortBy.PremiereDate, SortOrder.Descending), + (ItemSortBy.ProductionYear, SortOrder.Descending), + (ItemSortBy.DateCreated, SortOrder.Descending) }; } else { query.OrderBy = new[] { - new ValueTuple<string, SortOrder>(ItemSortBy.DateCreated, SortOrder.Descending) + (ItemSortBy.DateCreated, SortOrder.Descending) }; } @@ -647,32 +681,35 @@ namespace Emby.Server.Implementations.Channels private async Task RefreshLatestChannelItems(IChannel channel, CancellationToken cancellationToken) { - var internalChannel = await GetChannel(channel, cancellationToken); + var internalChannel = await GetChannel(channel, cancellationToken).ConfigureAwait(false); - var query = new InternalItemsQuery(); - query.Parent = internalChannel; - query.EnableTotalRecordCount = false; - query.ChannelIds = new Guid[] { internalChannel.Id }; + var query = new InternalItemsQuery + { + Parent = internalChannel, + EnableTotalRecordCount = false, + ChannelIds = new Guid[] { internalChannel.Id } + }; var result = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); foreach (var item in result.Items) { - var folder = item as Folder; - - if (folder != null) + if (item is Folder folder) { - await GetChannelItemsInternal(new InternalItemsQuery - { - Parent = folder, - EnableTotalRecordCount = false, - ChannelIds = new Guid[] { internalChannel.Id } - - }, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + await GetChannelItemsInternal( + new InternalItemsQuery + { + Parent = folder, + EnableTotalRecordCount = false, + ChannelIds = new Guid[] { internalChannel.Id } + }, + new SimpleProgress<double>(), + cancellationToken).ConfigureAwait(false); } } } + /// <inheritdoc /> public async Task<QueryResult<BaseItem>> GetChannelItemsInternal(InternalItemsQuery query, IProgress<double> progress, CancellationToken cancellationToken) { // Get the internal channel entity @@ -683,7 +720,8 @@ namespace Emby.Server.Implementations.Channels var parentItem = query.ParentId == Guid.Empty ? channel : _libraryManager.GetItemById(query.ParentId); - var itemsResult = await GetChannelItems(channelProvider, + var itemsResult = await GetChannelItems( + channelProvider, query.User, parentItem is Channel ? null : parentItem.ExternalId, null, @@ -695,22 +733,30 @@ namespace Emby.Server.Implementations.Channels { query.Parent = channel; } + query.ChannelIds = Array.Empty<Guid>(); // Not yet sure why this is causing a problem query.GroupByPresentationUniqueKey = false; - //_logger.LogDebug("GetChannelItemsInternal"); - // null if came from cache if (itemsResult != null) { - var internalItems = itemsResult.Items - .Select(i => GetChannelItemEntity(i, channelProvider, channel.Id, parentItem, cancellationToken)) - .ToArray(); + var items = itemsResult.Items; + var itemsLen = items.Count; + var internalItems = new Guid[itemsLen]; + for (int i = 0; i < itemsLen; i++) + { + internalItems[i] = (await GetChannelItemEntityAsync( + items[i], + channelProvider, + channel.Id, + parentItem, + cancellationToken).ConfigureAwait(false)).Id; + } var existingIds = _libraryManager.GetItemIds(query); - var deadIds = existingIds.Except(internalItems.Select(i => i.Id)) + var deadIds = existingIds.Except(internalItems) .ToArray(); foreach (var deadId in deadIds) @@ -718,12 +764,15 @@ namespace Emby.Server.Implementations.Channels var deadItem = _libraryManager.GetItemById(deadId); if (deadItem != null) { - _libraryManager.DeleteItem(deadItem, new DeleteOptions - { - DeleteFileLocation = false, - DeleteFromExternalProvider = false - - }, parentItem, false); + _libraryManager.DeleteItem( + deadItem, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + parentItem, + false); } } } @@ -731,6 +780,7 @@ namespace Emby.Server.Implementations.Channels return _libraryManager.GetItemsResult(query); } + /// <inheritdoc /> public async Task<QueryResult<BaseItemDto>> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) { var internalResult = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); @@ -746,15 +796,15 @@ namespace Emby.Server.Implementations.Channels return result; } - private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); - private async Task<ChannelItemResult> GetChannelItems(IChannel channel, + private async Task<ChannelItemResult> GetChannelItems( + IChannel channel, User user, string externalFolderId, ChannelItemSortField? sortField, bool sortDescending, CancellationToken cancellationToken) { - var userId = user == null ? null : user.Id.ToString("N"); + var userId = user?.Id.ToString("N", CultureInfo.InvariantCulture); var cacheLength = CacheLength; var cachePath = GetChannelDataCachePath(channel, userId, externalFolderId, sortField, sortDescending); @@ -763,7 +813,8 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); + var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { return null; @@ -772,11 +823,9 @@ namespace Emby.Server.Implementations.Channels } catch (FileNotFoundException) { - } catch (IOException) { - } await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -787,7 +836,8 @@ namespace Emby.Server.Implementations.Channels { if (_fileSystem.GetLastWriteTimeUtc(cachePath).Add(cacheLength) > DateTime.UtcNow) { - var cachedResult = _jsonSerializer.DeserializeFromFile<ChannelItemResult>(cachePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cachePath); + var cachedResult = await JsonSerializer.DeserializeAsync<ChannelItemResult>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); if (cachedResult != null) { return null; @@ -796,16 +846,14 @@ namespace Emby.Server.Implementations.Channels } catch (FileNotFoundException) { - } catch (IOException) { - } var query = new InternalChannelItemQuery { - UserId = user == null ? Guid.Empty : user.Id, + UserId = user?.Id ?? Guid.Empty, SortBy = sortField, SortDescending = sortDescending, FolderId = externalFolderId @@ -820,7 +868,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; } @@ -830,53 +878,56 @@ 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); } } - private string GetChannelDataCachePath(IChannel channel, + private string GetChannelDataCachePath( + IChannel channel, string userId, string externalFolderId, ChannelItemSortField? sortField, bool sortDescending) { - var channelId = GetInternalChannelId(channel.Name).ToString("N"); + var channelId = GetInternalChannelId(channel.Name).ToString("N", CultureInfo.InvariantCulture); var userCacheKey = string.Empty; - var hasCacheKey = channel as IHasCacheKey; - if (hasCacheKey != null) + if (channel is IHasCacheKey hasCacheKey) { userCacheKey = hasCacheKey.GetCacheKey(userId) ?? string.Empty; } - var filename = string.IsNullOrEmpty(externalFolderId) ? "root" : externalFolderId.GetMD5().ToString("N"); + var filename = string.IsNullOrEmpty(externalFolderId) ? "root" : externalFolderId.GetMD5().ToString("N", CultureInfo.InvariantCulture); filename += userCacheKey; - var version = ((channel.DataVersion ?? string.Empty) + "2").GetMD5().ToString("N"); + var version = ((channel.DataVersion ?? string.Empty) + "2").GetMD5().ToString("N", CultureInfo.InvariantCulture); if (sortField.HasValue) { filename += "-sortField-" + sortField.Value; } + if (sortDescending) { filename += "-sortDescending"; } - filename = filename.GetMD5().ToString("N"); + filename = filename.GetMD5().ToString("N", CultureInfo.InvariantCulture); - return Path.Combine(_config.ApplicationPaths.CachePath, + return Path.Combine( + _config.ApplicationPaths.CachePath, "channels", channelId, version, @@ -920,7 +971,7 @@ namespace Emby.Server.Implementations.Channels return item; } - private BaseItem GetChannelItemEntity(ChannelItemInfo info, IChannel channelProvider, Guid internalChannelId, BaseItem parentFolder, CancellationToken cancellationToken) + private async Task<BaseItem> GetChannelItemEntityAsync(ChannelItemInfo info, IChannel channelProvider, Guid internalChannelId, BaseItem parentFolder, CancellationToken cancellationToken) { var parentFolderId = parentFolder.Id; @@ -930,60 +981,32 @@ namespace Emby.Server.Implementations.Channels if (info.Type == ChannelItemType.Folder) { - if (info.FolderType == ChannelFolderType.MusicAlbum) - { - item = GetItemById<MusicAlbum>(info.Id, channelProvider.Name, out isNew); - } - else if (info.FolderType == ChannelFolderType.MusicArtist) - { - item = GetItemById<MusicArtist>(info.Id, channelProvider.Name, out isNew); - } - else if (info.FolderType == ChannelFolderType.PhotoAlbum) - { - item = GetItemById<PhotoAlbum>(info.Id, channelProvider.Name, out isNew); - } - else if (info.FolderType == ChannelFolderType.Series) + item = info.FolderType switch { - item = GetItemById<Series>(info.Id, channelProvider.Name, out isNew); - } - else if (info.FolderType == ChannelFolderType.Season) - { - item = GetItemById<Season>(info.Id, channelProvider.Name, out isNew); - } - else - { - item = GetItemById<Folder>(info.Id, channelProvider.Name, out isNew); - } + ChannelFolderType.MusicAlbum => GetItemById<MusicAlbum>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.MusicArtist => GetItemById<MusicArtist>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.PhotoAlbum => GetItemById<PhotoAlbum>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.Series => GetItemById<Series>(info.Id, channelProvider.Name, out isNew), + ChannelFolderType.Season => GetItemById<Season>(info.Id, channelProvider.Name, out isNew), + _ => GetItemById<Folder>(info.Id, channelProvider.Name, out isNew) + }; } else if (info.MediaType == ChannelMediaType.Audio) { - if (info.ContentType == ChannelMediaContentType.Podcast) - { - item = GetItemById<AudioBook>(info.Id, channelProvider.Name, out isNew); - } - else - { - item = GetItemById<Audio>(info.Id, channelProvider.Name, out isNew); - } + item = info.ContentType == ChannelMediaContentType.Podcast + ? GetItemById<AudioBook>(info.Id, channelProvider.Name, out isNew) + : GetItemById<Audio>(info.Id, channelProvider.Name, out isNew); } else { - if (info.ContentType == ChannelMediaContentType.Episode) - { - item = GetItemById<Episode>(info.Id, channelProvider.Name, out isNew); - } - else if (info.ContentType == ChannelMediaContentType.Movie) - { - item = GetItemById<Movie>(info.Id, channelProvider.Name, out isNew); - } - else if (info.ContentType == ChannelMediaContentType.Trailer || info.ExtraType == ExtraType.Trailer) - { - item = GetItemById<Trailer>(info.Id, channelProvider.Name, out isNew); - } - else + item = info.ContentType switch { - item = GetItemById<Video>(info.Id, channelProvider.Name, out isNew); - } + ChannelMediaContentType.Episode => GetItemById<Episode>(info.Id, channelProvider.Name, out isNew), + ChannelMediaContentType.Movie => GetItemById<Movie>(info.Id, channelProvider.Name, out isNew), + var x when x == ChannelMediaContentType.Trailer || info.ExtraType == ExtraType.Trailer + => GetItemById<Trailer>(info.Id, channelProvider.Name, out isNew), + _ => GetItemById<Video>(info.Id, channelProvider.Name, out isNew) + }; } var enableMediaProbe = channelProvider is ISupportsMediaProbe; @@ -992,7 +1015,6 @@ namespace Emby.Server.Implementations.Channels { item.RunTimeTicks = null; } - else if (isNew || !enableMediaProbe) { item.RunTimeTicks = info.RunTimeTicks; @@ -1025,26 +1047,24 @@ namespace Emby.Server.Implementations.Channels } } - var hasArtists = item as IHasArtist; - if (hasArtists != null) + if (item is IHasArtist hasArtists) { hasArtists.Artists = info.Artists.ToArray(); } - var hasAlbumArtists = item as IHasAlbumArtist; - if (hasAlbumArtists != null) + if (item is IHasAlbumArtist hasAlbumArtists) { hasAlbumArtists.AlbumArtists = info.AlbumArtists.ToArray(); } - var trailer = item as Trailer; - if (trailer != null) + if (item is Trailer trailer) { if (!info.TrailerTypes.SequenceEqual(trailer.TrailerTypes)) { _logger.LogDebug("Forcing update due to TrailerTypes {0}", item.Name); forceUpdate = true; } + trailer.TrailerTypes = info.TrailerTypes.ToArray(); } @@ -1056,18 +1076,19 @@ namespace Emby.Server.Implementations.Channels } // was used for status - //if (!string.Equals(item.ExternalEtag ?? string.Empty, info.Etag ?? string.Empty, StringComparison.Ordinal)) - //{ + // if (!string.Equals(item.ExternalEtag ?? string.Empty, info.Etag ?? string.Empty, StringComparison.Ordinal)) + // { // item.ExternalEtag = info.Etag; // forceUpdate = true; // _logger.LogDebug("Forcing update due to ExternalEtag {0}", item.Name); - //} + // } if (!internalChannelId.Equals(item.ChannelId)) { forceUpdate = true; _logger.LogDebug("Forcing update due to ChannelId {0}", item.Name); } + item.ChannelId = internalChannelId; if (!item.ParentId.Equals(parentFolderId)) @@ -1075,16 +1096,17 @@ namespace Emby.Server.Implementations.Channels forceUpdate = true; _logger.LogDebug("Forcing update due to parent folder Id {0}", item.Name); } + item.ParentId = parentFolderId; - var hasSeries = item as IHasSeries; - if (hasSeries != null) + if (item is IHasSeries hasSeries) { if (!string.Equals(hasSeries.SeriesName, info.SeriesName, StringComparison.OrdinalIgnoreCase)) { forceUpdate = true; _logger.LogDebug("Forcing update due to SeriesName {0}", item.Name); } + hasSeries.SeriesName = info.SeriesName; } @@ -1093,24 +1115,23 @@ namespace Emby.Server.Implementations.Channels forceUpdate = true; _logger.LogDebug("Forcing update due to ExternalId {0}", item.Name); } + item.ExternalId = info.Id; - var channelAudioItem = item as Audio; - if (channelAudioItem != null) + if (item is Audio channelAudioItem) { channelAudioItem.ExtraType = info.ExtraType; var mediaSource = info.MediaSources.FirstOrDefault(); - item.Path = mediaSource == null ? null : mediaSource.Path; + item.Path = mediaSource?.Path; } - var channelVideoItem = item as Video; - if (channelVideoItem != null) + if (item is Video channelVideoItem) { channelVideoItem.ExtraType = info.ExtraType; var mediaSource = info.MediaSources.FirstOrDefault(); - item.Path = mediaSource == null ? null : mediaSource.Path; + item.Path = mediaSource?.Path; } if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary)) @@ -1152,24 +1173,24 @@ namespace Emby.Server.Implementations.Channels } else if (forceUpdate) { - item.UpdateToRepository(ItemUpdateType.None, cancellationToken); + await item.UpdateToRepositoryAsync(ItemUpdateType.None, cancellationToken).ConfigureAwait(false); } if ((isNew || forceUpdate) && info.Type == ChannelItemType.Media) { 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); } } - if (isNew || forceUpdate || item.DateLastRefreshed == default(DateTime)) + if (isNew || forceUpdate || item.DateLastRefreshed == default) { - _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), RefreshPriority.Normal); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.Normal); } return item; diff --git a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs index ad6c537ef..2391eed42 100644 --- a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs +++ b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs @@ -9,21 +9,34 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Channels { + /// <summary> + /// A task to remove all non-installed channels from the database. + /// </summary> public class ChannelPostScanTask { private readonly IChannelManager _channelManager; - private readonly IUserManager _userManager; private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; - public ChannelPostScanTask(IChannelManager channelManager, IUserManager userManager, ILogger logger, ILibraryManager libraryManager) + /// <summary> + /// Initializes a new instance of the <see cref="ChannelPostScanTask"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + public ChannelPostScanTask(IChannelManager channelManager, ILogger logger, ILibraryManager libraryManager) { _channelManager = channelManager; - _userManager = userManager; _logger = logger; _libraryManager = libraryManager; } + /// <summary> + /// Runs this task. + /// </summary> + /// <param name="progress">The progress.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The completed task.</returns> public Task Run(IProgress<double> progress, CancellationToken cancellationToken) { CleanDatabase(cancellationToken); @@ -32,71 +45,55 @@ namespace Emby.Server.Implementations.Channels return Task.CompletedTask; } - public static string GetUserDistinctValue(User user) - { - var channels = user.Policy.EnabledChannels - .OrderBy(i => i) - .ToList(); - - return string.Join("|", channels.ToArray()); - } - private void CleanDatabase(CancellationToken cancellationToken) { var installedChannelIds = ((ChannelManager)_channelManager).GetInstalledChannelIds(); - var databaseIds = _libraryManager.GetItemIds(new InternalItemsQuery + var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(Channel).Name } + IncludeItemTypes = new[] { nameof(Channel) }, + ExcludeItemIds = installedChannelIds.ToArray() }); - var invalidIds = databaseIds - .Except(installedChannelIds) - .ToList(); - - foreach (var id in invalidIds) + foreach (var channel in uninstalledChannels) { cancellationToken.ThrowIfCancellationRequested(); - CleanChannel(id, cancellationToken); + CleanChannel((Channel)channel, cancellationToken); } } - private void CleanChannel(Guid id, CancellationToken cancellationToken) + private void CleanChannel(Channel channel, CancellationToken cancellationToken) { - _logger.LogInformation("Cleaning channel {0} from database", id); + _logger.LogInformation("Cleaning channel {0} from database", channel.Id); // Delete all channel items - var allIds = _libraryManager.GetItemIds(new InternalItemsQuery + var items = _libraryManager.GetItemList(new InternalItemsQuery { - ChannelIds = new[] { id } + ChannelIds = new[] { channel.Id } }); - foreach (var deleteId in allIds) + foreach (var item in items) { cancellationToken.ThrowIfCancellationRequested(); - DeleteItem(deleteId); + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); } // Finally, delete the channel itself - DeleteItem(id); - } - - private void DeleteItem(Guid id) - { - var item = _libraryManager.GetItemById(id); - - if (item == null) - { - return; - } - - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false - - }, false); + _libraryManager.DeleteItem( + channel, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); } } } diff --git a/Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs b/Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs index 303a8ac7b..e5dde48d8 100644 --- a/Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs +++ b/Emby.Server.Implementations/Channels/RefreshChannelsScheduledTask.cs @@ -5,60 +5,84 @@ using System.Threading.Tasks; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Channels { + /// <summary> + /// The "Refresh Channels" scheduled task. + /// </summary> public class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask { private readonly IChannelManager _channelManager; - private readonly IUserManager _userManager; - private readonly ILogger _logger; + private readonly ILogger<RefreshChannelsScheduledTask> _logger; private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; - public RefreshChannelsScheduledTask(IChannelManager channelManager, IUserManager userManager, ILogger logger, ILibraryManager libraryManager) + /// <summary> + /// Initializes a new instance of the <see cref="RefreshChannelsScheduledTask"/> class. + /// </summary> + /// <param name="channelManager">The channel manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="localization">The localization manager.</param> + public RefreshChannelsScheduledTask( + IChannelManager channelManager, + ILogger<RefreshChannelsScheduledTask> logger, + ILibraryManager libraryManager, + ILocalizationManager localization) { _channelManager = channelManager; - _userManager = userManager; _logger = logger; _libraryManager = libraryManager; + _localization = localization; } - public string Name => "Refresh Channels"; + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TasksRefreshChannels"); - public string Description => "Refreshes internet channel information."; + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TasksRefreshChannelsDescription"); - public string Category => "Internet Channels"; + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksChannelsCategory"); + /// <inheritdoc /> public bool IsHidden => ((ChannelManager)_channelManager).Channels.Length == 0; + /// <inheritdoc /> public bool IsEnabled => true; + /// <inheritdoc /> public bool IsLogged => true; + /// <inheritdoc /> + public string Key => "RefreshInternetChannels"; + + /// <inheritdoc /> public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) { var manager = (ChannelManager)_channelManager; await manager.RefreshChannels(new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); - await new ChannelPostScanTask(_channelManager, _userManager, _logger, _libraryManager).Run(progress, cancellationToken) + await new ChannelPostScanTask(_channelManager, _logger, _libraryManager).Run(progress, cancellationToken) .ConfigureAwait(false); } - /// <summary> - /// Creates the triggers that define when the task will run - /// </summary> + /// <inheritdoc /> 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 + } }; } - - public string Key => "RefreshInternetChannels"; } } diff --git a/Emby.Server.Implementations/Collections/CollectionImageProvider.cs b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs index cdfb5cadf..ca8409402 100644 --- a/Emby.Server.Implementations/Collections/CollectionImageProvider.cs +++ b/Emby.Server.Implementations/Collections/CollectionImageProvider.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using Emby.Server.Implementations.Images; @@ -14,8 +13,18 @@ using MediaBrowser.Model.IO; namespace Emby.Server.Implementations.Collections { + /// <summary> + /// A collection image provider. + /// </summary> public class CollectionImageProvider : BaseDynamicImageProvider<BoxSet> { + /// <summary> + /// Initializes a new instance of the <see cref="CollectionImageProvider"/> class. + /// </summary> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="applicationPaths">The application paths.</param> + /// <param name="imageProcessor">The image processor.</param> public CollectionImageProvider( IFileSystem fileSystem, IProviderManager providerManager, @@ -25,6 +34,7 @@ namespace Emby.Server.Implementations.Collections { } + /// <inheritdoc /> protected override bool Supports(BaseItem item) { // Right now this is the only way to prevent this image from getting created ahead of internet image providers @@ -36,7 +46,8 @@ namespace Emby.Server.Implementations.Collections return base.Supports(item); } - protected override List<BaseItem> GetItemsWithImages(BaseItem item) + /// <inheritdoc /> + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) { var playlist = (BoxSet)item; @@ -47,13 +58,10 @@ namespace Emby.Server.Implementations.Collections var episode = subItem as Episode; - if (episode != null) + var series = episode?.Series; + if (series != null && series.HasImage(ImageType.Primary)) { - var series = episode.Series; - if (series != null && series.HasImage(ImageType.Primary)) - { - return series; - } + return series; } if (subItem.HasImage(ImageType.Primary)) @@ -74,13 +82,13 @@ 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()) - .OrderBy(i => Guid.NewGuid()) - .ToList(); + .ToList()!; // Again... the list doesn't contain any null values } - protected override string CreateImage(BaseItem item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) + /// <inheritdoc /> + protected override string CreateImage(BaseItem item, IReadOnlyCollection<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) { return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); } diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 2b99e0ddf..79ef70fff 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -4,13 +4,12 @@ using System.IO; using System.Linq; using System.Threading; 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; @@ -20,20 +19,29 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Collections { + /// <summary> + /// The collection manager. + /// </summary> public class CollectionManager : ICollectionManager { private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _iLibraryMonitor; - private readonly ILogger _logger; + private readonly ILogger<CollectionManager> _logger; private readonly IProviderManager _providerManager; private readonly ILocalizationManager _localizationManager; - private IApplicationPaths _appPaths; - - public event EventHandler<CollectionCreatedEventArgs> CollectionCreated; - public event EventHandler<CollectionModifiedEventArgs> ItemsAddedToCollection; - public event EventHandler<CollectionModifiedEventArgs> ItemsRemovedFromCollection; - + private readonly IApplicationPaths _appPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="CollectionManager"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="localizationManager">The localization manager.</param> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="iLibraryMonitor">The library monitor.</param> + /// <param name="loggerFactory">The logger factory.</param> + /// <param name="providerManager">The provider manager.</param> public CollectionManager( ILibraryManager libraryManager, IApplicationPaths appPaths, @@ -46,12 +54,21 @@ namespace Emby.Server.Implementations.Collections _libraryManager = libraryManager; _fileSystem = fileSystem; _iLibraryMonitor = iLibraryMonitor; - _logger = loggerFactory.CreateLogger(nameof(CollectionManager)); + _logger = loggerFactory.CreateLogger<CollectionManager>(); _providerManager = providerManager; _localizationManager = localizationManager; _appPaths = appPaths; } + /// <inheritdoc /> + public event EventHandler<CollectionCreatedEventArgs>? CollectionCreated; + + /// <inheritdoc /> + public event EventHandler<CollectionModifiedEventArgs>? ItemsAddedToCollection; + + /// <inheritdoc /> + public event EventHandler<CollectionModifiedEventArgs>? ItemsRemovedFromCollection; + private IEnumerable<Folder> FindFolders(string path) { return _libraryManager @@ -61,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) @@ -80,14 +95,14 @@ namespace Emby.Server.Implementations.Collections var libraryOptions = new LibraryOptions { - PathInfos = new[] { new MediaPathInfo { Path = path } }, + PathInfos = new[] { new MediaPathInfo(path) }, EnableRealtimeMonitor = false, SaveLocalMetadata = true }; 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(); } @@ -97,21 +112,22 @@ 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 ? - new List<BoxSet>() : - folder.GetChildren(user, true).OfType<BoxSet>(); + return folder == null + ? Enumerable.Empty<BoxSet>() + : folder.GetChildren(user, true).OfType<BoxSet>(); } - public BoxSet CreateCollection(CollectionCreationOptions options) + /// <inheritdoc /> + public async Task<BoxSet> CreateCollectionAsync(CollectionCreationOptions options) { var name = options.Name; @@ -120,7 +136,7 @@ namespace Emby.Server.Implementations.Collections // This could cause it to get re-resolved as a plain folder var folderName = _fileSystem.GetValidFilename(name) + " [boxset]"; - var parentFolder = GetCollectionsFolder(true).Result; + var parentFolder = await GetCollectionsFolder(true).ConfigureAwait(false); if (parentFolder == null) { @@ -144,20 +160,24 @@ 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) { - AddToCollection(collection.Id, options.ItemIdList, false, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - // The initial adding of items is going to create a local metadata file - // This will cause internet metadata to be skipped as a result - MetadataRefreshMode = MetadataRefreshMode.FullRefresh - }); + await AddToCollectionAsync( + collection.Id, + options.ItemIdList.Select(x => new Guid(x)), + false, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + // The initial adding of items is going to create a local metadata file + // This will cause internet metadata to be skipped as a result + MetadataRefreshMode = MetadataRefreshMode.FullRefresh + }).ConfigureAwait(false); } else { - _providerManager.QueueRefresh(collection.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(collection.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); } CollectionCreated?.Invoke(this, new CollectionCreatedEventArgs @@ -175,21 +195,13 @@ namespace Emby.Server.Implementations.Collections } } - public void AddToCollection(Guid collectionId, IEnumerable<string> ids) - { - AddToCollection(collectionId, ids, true, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem))); - } - - public void AddToCollection(Guid collectionId, IEnumerable<Guid> ids) - { - AddToCollection(collectionId, ids.Select(i => i.ToString("N")), true, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem))); - } + /// <inheritdoc /> + public Task AddToCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds) + => AddToCollectionAsync(collectionId, itemIds, true, new MetadataRefreshOptions(new DirectoryService(_fileSystem))); - private void AddToCollection(Guid collectionId, IEnumerable<string> ids, bool fireEvent, MetadataRefreshOptions refreshOptions) + 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"); } @@ -202,15 +214,14 @@ namespace Emby.Server.Implementations.Collections foreach (var id in ids) { - var guidId = new Guid(id); - var item = _libraryManager.GetItemById(guidId); + var item = _libraryManager.GetItemById(id); if (item == null) { throw new ArgumentException("No item exists with the supplied Id"); } - if (!currentLinkedChildrenIds.Contains(guidId)) + if (!currentLinkedChildrenIds.Contains(id)) { itemList.Add(item); @@ -227,32 +238,22 @@ namespace Emby.Server.Implementations.Collections collection.UpdateRatingToItems(linkedChildrenList); - collection.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); refreshOptions.ForceSave = true; _providerManager.QueueRefresh(collection.Id, refreshOptions, RefreshPriority.High); if (fireEvent) { - ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs - { - Collection = collection, - ItemsChanged = itemList - }); + ItemsAddedToCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList)); } } } - public void RemoveFromCollection(Guid collectionId, IEnumerable<string> itemIds) + /// <inheritdoc /> + public async Task RemoveFromCollectionAsync(Guid collectionId, IEnumerable<Guid> itemIds) { - RemoveFromCollection(collectionId, itemIds.Select(i => new Guid(i))); - } - - public void RemoveFromCollection(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"); } @@ -285,128 +286,77 @@ namespace Emby.Server.Implementations.Collections collection.LinkedChildren = collection.LinkedChildren.Except(list).ToArray(); } - collection.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - _providerManager.QueueRefresh(collection.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - ForceSave = true - }, RefreshPriority.High); + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + _providerManager.QueueRefresh( + collection.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + RefreshPriority.High); - ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs - { - Collection = collection, - ItemsChanged = itemList - }); + ItemsRemovedFromCollection?.Invoke(this, new CollectionModifiedEventArgs(collection, itemList)); } + /// <inheritdoc /> public IEnumerable<BaseItem> CollapseItemsWithinBoxSets(IEnumerable<BaseItem> items, User user) { var results = new Dictionary<Guid, BaseItem>(); - var allBoxsets = GetCollections(user).ToList(); + var allBoxSets = GetCollections(user).ToList(); foreach (var item in items) { - var grouping = item as ISupportsBoxSetGrouping; - - if (grouping == null) - { - results[item.Id] = item; - } - else + if (item is ISupportsBoxSetGrouping) { var itemId = item.Id; - var currentBoxSets = allBoxsets - .Where(i => i.ContainsLinkedChildByItemId(itemId)) - .ToList(); - - if (currentBoxSets.Count > 0) + var itemIsInBoxSet = false; + foreach (var boxSet in allBoxSets) { - foreach (var boxset in currentBoxSets) + if (!boxSet.ContainsLinkedChildByItemId(itemId)) { - results[boxset.Id] = boxset; + continue; } - } - else - { - results[item.Id] = item; - } - } - } - return results.Values; - } - } + itemIsInBoxSet = true; - public class CollectionManagerEntryPoint : IServerEntryPoint - { - private readonly CollectionManager _collectionManager; - private readonly IServerConfigurationManager _config; - private ILogger _logger; + results.TryAdd(boxSet.Id, boxSet); + } - public CollectionManagerEntryPoint(ICollectionManager collectionManager, IServerConfigurationManager config, ILogger logger) - { - _collectionManager = (CollectionManager)collectionManager; - _config = config; - _logger = logger; - } + // skip any item that is in a box set + if (itemIsInBoxSet) + { + continue; + } - public async Task RunAsync() - { - if (!_config.Configuration.CollectionsUpgraded && _config.Configuration.IsStartupWizardCompleted) - { - var path = _collectionManager.GetCollectionsFolderPath(); + var alreadyInResults = false; - if (Directory.Exists(path)) - { - try + // 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) { - await _collectionManager.EnsureLibraryFolder(path, true).ConfigureAwait(false); + foreach (var childId in video.GetLocalAlternateVersionIds()) + { + if (!results.ContainsKey(childId)) + { + continue; + } + + alreadyInResults = true; + break; + } } - catch (Exception ex) + + if (alreadyInResults) { - _logger.LogError(ex, "Error creating camera uploads library"); + continue; } - - _config.Configuration.CollectionsUpgraded = true; - _config.SaveConfiguration(); } - } - } - #region IDisposable Support - private bool disposedValue = false; // To detect redundant calls - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - // TODO: dispose managed state (managed objects). - } - - // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. - // TODO: set large fields to null. - - disposedValue = true; + results[item.Id] = item; } - } - // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. - // ~CollectionManagerEntryPoint() { - // // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - // Dispose(false); - // } - - // This code added to correctly implement the disposable pattern. - public void Dispose() - { - // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - Dispose(true); - // TODO: uncomment the following line if the finalizer is overridden above. - // GC.SuppressFinalize(this); + return results.Values; } - #endregion } } diff --git a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs index 18e279c2f..ff5602f24 100644 --- a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -1,12 +1,14 @@ +#nullable disable + using System; -using System.Collections.Generic; +using System.Globalization; using System.IO; using Emby.Server.Implementations.AppBase; +using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Events; using MediaBrowser.Model.IO; using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; @@ -14,11 +16,10 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Configuration { /// <summary> - /// Class ServerConfigurationManager + /// Class ServerConfigurationManager. /// </summary> public class ServerConfigurationManager : BaseConfigurationManager, IServerConfigurationManager { - /// <summary> /// Initializes a new instance of the <see cref="ServerConfigurationManager" /> class. /// </summary> @@ -32,6 +33,9 @@ namespace Emby.Server.Implementations.Configuration UpdateMetadataPath(); } + /// <summary> + /// Configuration updating event. + /// </summary> public event EventHandler<GenericEventArgs<ServerConfiguration>> ConfigurationUpdating; /// <summary> @@ -62,176 +66,59 @@ namespace Emby.Server.Implementations.Configuration base.OnConfigurationUpdated(); } - public override void AddParts(IEnumerable<IConfigurationFactory> factories) - { - base.AddParts(factories); - - UpdateTranscodingTempPath(); - } - /// <summary> /// Updates the metadata path. /// </summary> + /// <exception cref="UnauthorizedAccessException">If the directory does not exist, and the caller does not have the required permission to create it.</exception> + /// <exception cref="NotSupportedException">If there is a custom path transcoding path specified, but it is invalid.</exception> + /// <exception cref="IOException">If the directory does not exist, and it also could not be created.</exception> private void UpdateMetadataPath() { - string metadataPath; - - if (string.IsNullOrWhiteSpace(Configuration.MetadataPath)) - { - metadataPath = GetInternalMetadataPath(); - } - else - { - metadataPath = Path.Combine(Configuration.MetadataPath, "metadata"); - } - - ((ServerApplicationPaths)ApplicationPaths).InternalMetadataPath = metadataPath; - } - - private string GetInternalMetadataPath() - { - return Path.Combine(ApplicationPaths.ProgramDataPath, "metadata"); - } - - /// <summary> - /// Updates the transcoding temporary path. - /// </summary> - private void UpdateTranscodingTempPath() - { - var encodingConfig = this.GetConfiguration<EncodingOptions>("encoding"); - - ((ServerApplicationPaths)ApplicationPaths).TranscodingTempPath = string.IsNullOrEmpty(encodingConfig.TranscodingTempPath) ? - null : - Path.Combine(encodingConfig.TranscodingTempPath, "transcoding-temp"); - } - - protected override void OnNamedConfigurationUpdated(string key, object configuration) - { - base.OnNamedConfigurationUpdated(key, configuration); - - if (string.Equals(key, "encoding", StringComparison.OrdinalIgnoreCase)) - { - UpdateTranscodingTempPath(); - } + ((ServerApplicationPaths)ApplicationPaths).InternalMetadataPath = string.IsNullOrWhiteSpace(Configuration.MetadataPath) + ? ApplicationPaths.DefaultInternalMetadataPath + : Configuration.MetadataPath; + Directory.CreateDirectory(ApplicationPaths.InternalMetadataPath); } /// <summary> /// Replaces the configuration. /// </summary> /// <param name="newConfiguration">The new configuration.</param> - /// <exception cref="DirectoryNotFoundException"></exception> + /// <exception cref="DirectoryNotFoundException">If the configuration path doesn't exist.</exception> public override void ReplaceConfiguration(BaseApplicationConfiguration newConfiguration) { var newConfig = (ServerConfiguration)newConfiguration; ValidateMetadataPath(newConfig); - ValidateSslCertificate(newConfig); - ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration> { Argument = newConfig }); + ConfigurationUpdating?.Invoke(this, new GenericEventArgs<ServerConfiguration>(newConfig)); base.ReplaceConfiguration(newConfiguration); } - - /// <summary> - /// Validates the SSL certificate. - /// </summary> - /// <param name="newConfig">The new configuration.</param> - /// <exception cref="DirectoryNotFoundException"></exception> - private void ValidateSslCertificate(BaseApplicationConfiguration newConfig) - { - var serverConfig = (ServerConfiguration)newConfig; - - var newPath = serverConfig.CertificatePath; - - if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(Configuration.CertificatePath ?? string.Empty, newPath)) - { - // Validate - if (!File.Exists(newPath)) - { - throw new FileNotFoundException(string.Format("Certificate file '{0}' does not exist.", newPath)); - } - } - } - /// <summary> /// Validates the metadata path. /// </summary> /// <param name="newConfig">The new configuration.</param> - /// <exception cref="DirectoryNotFoundException"></exception> + /// <exception cref="DirectoryNotFoundException">The new config path doesn't exist.</exception> private void ValidateMetadataPath(ServerConfiguration newConfig) { var newPath = newConfig.MetadataPath; if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(Configuration.MetadataPath ?? string.Empty, newPath)) + && !string.Equals(Configuration.MetadataPath, newPath, StringComparison.Ordinal)) { - // Validate if (!Directory.Exists(newPath)) { - throw new FileNotFoundException(string.Format("{0} does not exist.", newPath)); + throw new DirectoryNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "{0} does not exist.", + newPath)); } EnsureWriteAccess(newPath); } } - - public bool SetOptimalValues() - { - var config = Configuration; - - var changed = false; - - if (!config.EnableCaseSensitiveItemIds) - { - config.EnableCaseSensitiveItemIds = true; - changed = true; - } - - if (!config.SkipDeserializationForBasicTypes) - { - config.SkipDeserializationForBasicTypes = true; - changed = true; - } - - if (!config.EnableSimpleArtistDetection) - { - config.EnableSimpleArtistDetection = true; - changed = true; - } - - if (!config.EnableNormalizedItemByNameIds) - { - config.EnableNormalizedItemByNameIds = true; - changed = true; - } - - if (!config.DisableLiveTvChannelUserDataName) - { - config.DisableLiveTvChannelUserDataName = true; - changed = true; - } - - if (!config.EnableNewOmdbSupport) - { - config.EnableNewOmdbSupport = true; - changed = true; - } - - if (!config.CameraUploadUpgraded) - { - config.CameraUploadUpgraded = true; - changed = true; - } - - if (!config.CollectionsUpgraded) - { - config.CollectionsUpgraded = true; - changed = true; - } - - return changed; - } } } diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index 30bfd8749..01dc728c1 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -1,12 +1,24 @@ using System.Collections.Generic; +using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; namespace Emby.Server.Implementations { + /// <summary> + /// Static class containing the default configuration options for the web server. + /// </summary> public static class ConfigurationOptions { - public static readonly Dictionary<string, string> Configuration = new Dictionary<string, string> + /// <summary> + /// Gets a new copy of the default configuration options. + /// </summary> + public static Dictionary<string, string> DefaultConfiguration => new Dictionary<string, string> { - {"HttpListenerHost:DefaultRedirectPath", "web/index.html"} + { HostWebClientKey, bool.TrueString }, + { DefaultRedirectKey, "web/index.html" }, + { FfmpegProbeSizeKey, "1G" }, + { FfmpegAnalyzeDurationKey, "200M" }, + { PlaylistsAllowDuplicatesKey, bool.FalseString }, + { BindToUnixSocketKey, bool.FalseString } }; } } diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 09fdbc856..673810c49 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -1,40 +1,94 @@ using System; -using System.IO; +using System.Collections.Generic; using System.Security.Cryptography; -using System.Text; +using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Cryptography; +using static MediaBrowser.Common.Cryptography.Constants; namespace Emby.Server.Implementations.Cryptography { + /// <summary> + /// Class providing abstractions over cryptographic functions. + /// </summary> public class CryptographyProvider : ICryptoProvider { - public Guid GetMD5(string str) - { - return new Guid(ComputeMD5(Encoding.Unicode.GetBytes(str))); - } + // FIXME: When we get DotNet Standard 2.1 we need to revisit how we do the crypto + // Currently supported hash methods from https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.cryptoconfig?view=netcore-2.1 + // there might be a better way to autogenerate this list as dotnet updates, but I couldn't find one + // Please note the default method of PBKDF2 is not included, it cannot be used to generate hashes cleanly as it is actually a pbkdf with sha1 + private static readonly HashSet<string> _supportedHashMethods = new HashSet<string>() + { + "MD5", + "System.Security.Cryptography.MD5", + "SHA", + "SHA1", + "System.Security.Cryptography.SHA1", + "SHA256", + "SHA-256", + "System.Security.Cryptography.SHA256", + "SHA384", + "SHA-384", + "System.Security.Cryptography.SHA384", + "SHA512", + "SHA-512", + "System.Security.Cryptography.SHA512" + }; + + /// <inheritdoc /> + public string DefaultHashMethod => "PBKDF2"; - public byte[] ComputeSHA1(byte[] bytes) + /// <inheritdoc /> + public IEnumerable<string> GetSupportedHashMethods() + => _supportedHashMethods; + + private byte[] PBKDF2(string method, byte[] bytes, byte[] salt, int iterations) { - using (var provider = SHA1.Create()) + // downgrading for now as we need this library to be dotnetstandard compliant + // with this downgrade we'll add a check to make sure we're on the downgrade method at the moment + if (method != DefaultHashMethod) { - return provider.ComputeHash(bytes); + throw new CryptographicException($"Cannot currently use PBKDF2 with requested hash method: {method}"); } + + using var r = new Rfc2898DeriveBytes(bytes, salt, iterations); + return r.GetBytes(32); } - public byte[] ComputeMD5(Stream str) + /// <inheritdoc /> + public byte[] ComputeHash(string hashMethod, byte[] bytes, byte[] salt) { - using (var provider = MD5.Create()) + if (hashMethod == DefaultHashMethod) { - return provider.ComputeHash(str); + return PBKDF2(hashMethod, bytes, salt, DefaultIterations); } - } - public byte[] ComputeMD5(byte[] bytes) - { - using (var provider = MD5.Create()) + if (!_supportedHashMethods.Contains(hashMethod)) { - return provider.ComputeHash(bytes); + throw new CryptographicException($"Requested hash method is not supported: {hashMethod}"); } + + using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}."); + if (salt.Length == 0) + { + return h.ComputeHash(bytes); + } + + byte[] salted = new byte[bytes.Length + salt.Length]; + Array.Copy(bytes, salted, bytes.Length); + Array.Copy(salt, 0, salted, bytes.Length, salt.Length); + return h.ComputeHash(salted); } + + /// <inheritdoc /> + public byte[] ComputeHashWithDefaultMethod(byte[] bytes, byte[] salt) + => PBKDF2(DefaultHashMethod, bytes, salt, DefaultIterations); + + /// <inheritdoc /> + public byte[] GenerateSalt() + => GenerateSalt(DefaultSaltLength); + + /// <inheritdoc /> + public byte[] GenerateSalt(int length) + => RandomNumberGenerator.GetBytes(length); } } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index fba81306b..73c31f49d 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -1,187 +1,166 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using Microsoft.Extensions.Logging; -using SQLitePCL; using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { public abstract class BaseSqliteRepository : IDisposable { - protected string DbFilePath { get; set; } - protected ReaderWriterLockSlim WriteLock; + private bool _disposed = false; - protected ILogger Logger { get; private set; } - - protected BaseSqliteRepository(ILogger logger) + /// <summary> + /// Initializes a new instance of the <see cref="BaseSqliteRepository"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + protected BaseSqliteRepository(ILogger<BaseSqliteRepository> logger) { Logger = logger; - - WriteLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion); } - protected TransactionMode TransactionMode => TransactionMode.Deferred; + /// <summary> + /// Gets or sets the path to the DB file. + /// </summary> + /// <value>Path to the DB file.</value> + protected string DbFilePath { get; set; } - protected TransactionMode ReadTransactionMode => TransactionMode.Deferred; + /// <summary> + /// Gets the logger. + /// </summary> + /// <value>The logger.</value> + protected ILogger<BaseSqliteRepository> Logger { get; } - internal static int ThreadSafeMode { get; set; } + /// <summary> + /// Gets the default connection flags. + /// </summary> + /// <value>The default connection flags.</value> + protected virtual ConnectionFlags DefaultConnectionFlags => ConnectionFlags.NoMutex; - static BaseSqliteRepository() - { - SQLite3.EnableSharedCache = false; + /// <summary> + /// Gets the transaction mode. + /// </summary> + /// <value>The transaction mode.</value>> + protected TransactionMode TransactionMode => TransactionMode.Deferred; - int rc = raw.sqlite3_config(raw.SQLITE_CONFIG_MEMSTATUS, 0); - //CheckOk(rc); + /// <summary> + /// Gets the transaction mode for read-only operations. + /// </summary> + /// <value>The transaction mode.</value> + protected TransactionMode ReadTransactionMode => TransactionMode.Deferred; - rc = raw.sqlite3_config(raw.SQLITE_CONFIG_MULTITHREAD, 1); - //rc = raw.sqlite3_config(raw.SQLITE_CONFIG_SINGLETHREAD, 1); - //rc = raw.sqlite3_config(raw.SQLITE_CONFIG_SERIALIZED, 1); - //CheckOk(rc); + /// <summary> + /// Gets the cache size. + /// </summary> + /// <value>The cache size or null.</value> + protected virtual int? CacheSize => null; - rc = raw.sqlite3_enable_shared_cache(1); + /// <summary> + /// Gets the journal mode. <see href="https://www.sqlite.org/pragma.html#pragma_journal_mode" />. + /// </summary> + /// <value>The journal mode.</value> + protected virtual string JournalMode => "TRUNCATE"; - ThreadSafeMode = raw.sqlite3_threadsafe(); - } + /// <summary> + /// Gets the page size. + /// </summary> + /// <value>The page size or null.</value> + protected virtual int? PageSize => null; + + /// <summary> + /// Gets the temp store mode. + /// </summary> + /// <value>The temp store mode.</value> + /// <see cref="TempStoreMode"/> + protected virtual TempStoreMode TempStore => TempStoreMode.Default; - private static bool _versionLogged; + /// <summary> + /// Gets the synchronous mode. + /// </summary> + /// <value>The synchronous mode or null.</value> + /// <see cref="SynchronousMode"/> + protected virtual SynchronousMode? Synchronous => null; - private string _defaultWal; - protected ManagedConnection _connection; + /// <summary> + /// Gets or sets the write lock. + /// </summary> + /// <value>The write lock.</value> + protected SemaphoreSlim WriteLock { get; set; } = new SemaphoreSlim(1, 1); - protected virtual bool EnableSingleConnection => true; + /// <summary> + /// Gets or sets the write connection. + /// </summary> + /// <value>The write connection.</value> + protected SQLiteDatabaseConnection WriteConnection { get; set; } - protected ManagedConnection CreateConnection(bool isReadOnly = false) + protected ManagedConnection GetConnection(bool readOnly = false) { - if (_connection != null) + WriteLock.Wait(); + if (WriteConnection != null) { - return _connection; + return new ManagedConnection(WriteConnection, WriteLock); } - lock (WriteLock) - { - if (!_versionLogged) - { - _versionLogged = true; - Logger.LogInformation("Sqlite version: " + SQLite3.Version); - Logger.LogInformation("Sqlite compiler options: " + string.Join(",", SQLite3.CompilerOptions.ToArray())); - } - - ConnectionFlags connectionFlags; - - if (isReadOnly) - { - //Logger.LogInformation("Opening read connection"); - //connectionFlags = ConnectionFlags.ReadOnly; - connectionFlags = ConnectionFlags.Create; - connectionFlags |= ConnectionFlags.ReadWrite; - } - else - { - //Logger.LogInformation("Opening write connection"); - connectionFlags = ConnectionFlags.Create; - connectionFlags |= ConnectionFlags.ReadWrite; - } - - if (EnableSingleConnection) - { - connectionFlags |= ConnectionFlags.PrivateCache; - } - else - { - connectionFlags |= ConnectionFlags.SharedCached; - } + WriteConnection = SQLite3.Open( + DbFilePath, + DefaultConnectionFlags | ConnectionFlags.Create | ConnectionFlags.ReadWrite, + null); - connectionFlags |= ConnectionFlags.NoMutex; - - var db = SQLite3.Open(DbFilePath, connectionFlags, null); - - try - { - if (string.IsNullOrWhiteSpace(_defaultWal)) - { - _defaultWal = db.Query("PRAGMA journal_mode").SelectScalarString().First(); - - Logger.LogInformation("Default journal_mode for {0} is {1}", DbFilePath, _defaultWal); - } - - var queries = new List<string> - { - //"PRAGMA cache size=-10000" - //"PRAGMA read_uncommitted = true", - "PRAGMA synchronous=Normal" - }; - - if (CacheSize.HasValue) - { - queries.Add("PRAGMA cache_size=" + CacheSize.Value.ToString(CultureInfo.InvariantCulture)); - } + if (CacheSize.HasValue) + { + WriteConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); + } - if (EnableTempStoreMemory) - { - queries.Add("PRAGMA temp_store = memory"); - } - else - { - queries.Add("PRAGMA temp_store = file"); - } + if (!string.IsNullOrWhiteSpace(JournalMode)) + { + WriteConnection.Execute("PRAGMA journal_mode=" + JournalMode); + } - foreach (var query in queries) - { - db.Execute(query); - } - } - catch - { - using (db) - { + if (Synchronous.HasValue) + { + WriteConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); + } - } + if (PageSize.HasValue) + { + WriteConnection.Execute("PRAGMA page_size=" + PageSize.Value); + } - throw; - } + WriteConnection.Execute("PRAGMA temp_store=" + (int)TempStore); - _connection = new ManagedConnection(db, false); + // Configuration and pragmas can affect VACUUM so it needs to be last. + WriteConnection.Execute("VACUUM"); - return _connection; - } + return new ManagedConnection(WriteConnection, WriteLock); } public IStatement PrepareStatement(ManagedConnection connection, string sql) - { - return connection.PrepareStatement(sql); - } - - public IStatement PrepareStatementSafe(ManagedConnection connection, string sql) - { - return connection.PrepareStatement(sql); - } + => connection.PrepareStatement(sql); public IStatement PrepareStatement(IDatabaseConnection connection, string sql) - { - return connection.PrepareStatement(sql); - } - - public IStatement PrepareStatementSafe(IDatabaseConnection connection, string sql) - { - return connection.PrepareStatement(sql); - } + => connection.PrepareStatement(sql); - public List<IStatement> PrepareAll(IDatabaseConnection connection, IEnumerable<string> sql) + public IStatement[] PrepareAll(IDatabaseConnection connection, IReadOnlyList<string> sql) { - return PrepareAllSafe(connection, sql); - } + int len = sql.Count; + IStatement[] statements = new IStatement[len]; + for (int i = 0; i < len; i++) + { + statements[i] = connection.PrepareStatement(sql[i]); + } - public List<IStatement> PrepareAllSafe(IDatabaseConnection connection, IEnumerable<string> sql) - { - return sql.Select(connection.PrepareStatement).ToList(); + 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")) { @@ -195,44 +174,34 @@ namespace Emby.Server.Implementations.Data } return false; - }, ReadTransactionMode); } - protected void RunDefaultInitialization(ManagedConnection db) + protected List<string> GetColumnNames(IDatabaseConnection connection, string table) { - var queries = new List<string> - { - "PRAGMA journal_mode=WAL", - "PRAGMA page_size=4096", - "PRAGMA synchronous=Normal" - }; + var columnNames = new List<string>(); - if (EnableTempStoreMemory) - { - queries.AddRange(new List<string> - { - "pragma default_temp_store = memory", - "pragma temp_store = memory" - }); - } - else + foreach (var row in connection.Query("PRAGMA table_info(" + table + ")")) { - queries.AddRange(new List<string> + if (row.TryGetString(1, out var columnName)) { - "pragma temp_store = file" - }); + columnNames.Add(columnName); + } } - db.ExecuteAll(string.Join(";", queries)); - Logger.LogInformation("PRAGMA synchronous=" + db.Query("PRAGMA synchronous").SelectScalarString().First()); + return columnNames; } - protected virtual bool EnableTempStoreMemory => false; + protected void AddColumn(IDatabaseConnection connection, string table, string columnName, string type, List<string> existingColumnNames) + { + if (existingColumnNames.Contains(columnName, StringComparer.OrdinalIgnoreCase)) + { + return; + } - protected virtual int? CacheSize => null; + connection.Execute("alter table " + table + " add column " + columnName + " " + type + " NULL"); + } - private bool _disposed; protected void CheckDisposed() { if (_disposed) @@ -241,139 +210,43 @@ namespace Emby.Server.Implementations.Data } } + /// <inheritdoc /> public void Dispose() { - _disposed = true; Dispose(true); + GC.SuppressFinalize(this); } - private readonly object _disposeLock = new object(); - /// <summary> /// Releases unmanaged and - optionally - managed resources. /// </summary> /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> protected virtual void Dispose(bool dispose) { - if (dispose) - { - DisposeConnection(); - } - } - - private void DisposeConnection() - { - try - { - lock (_disposeLock) - { - using (WriteLock.Write()) - { - if (_connection != null) - { - using (_connection) - { - _connection.Close(); - } - _connection = null; - } - - CloseConnection(); - } - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error disposing database"); - } - } - - protected virtual void CloseConnection() - { - - } - - protected List<string> GetColumnNames(IDatabaseConnection connection, string table) - { - var list = new List<string>(); - - foreach (var row in connection.Query("PRAGMA table_info(" + table + ")")) - { - if (row[1].SQLiteType != SQLiteType.Null) - { - var name = row[1].ToString(); - - list.Add(name); - } - } - - return list; - } - - protected void AddColumn(IDatabaseConnection connection, string table, string columnName, string type, List<string> existingColumnNames) - { - if (existingColumnNames.Contains(columnName, StringComparer.OrdinalIgnoreCase)) + if (_disposed) { return; } - connection.Execute("alter table " + table + " add column " + columnName + " " + type + " NULL"); - } - } - - public static class ReaderWriterLockSlimExtensions - { - private sealed class ReadLockToken : IDisposable - { - private ReaderWriterLockSlim _sync; - public ReadLockToken(ReaderWriterLockSlim sync) - { - _sync = sync; - sync.EnterReadLock(); - } - public void Dispose() + if (dispose) { - if (_sync != null) + WriteLock.Wait(); + try { - _sync.ExitReadLock(); - _sync = null; + WriteConnection?.Dispose(); } - } - } - private sealed class WriteLockToken : IDisposable - { - private ReaderWriterLockSlim _sync; - public WriteLockToken(ReaderWriterLockSlim sync) - { - _sync = sync; - sync.EnterWriteLock(); - } - public void Dispose() - { - if (_sync != null) + finally { - _sync.ExitWriteLock(); - _sync = null; + WriteLock.Release(); } + + WriteLock.Dispose(); } - } - public static IDisposable Read(this ReaderWriterLockSlim obj) - { - //if (BaseSqliteRepository.ThreadSafeMode > 0) - //{ - // return new DummyToken(); - //} - return new WriteLockToken(obj); - } + WriteConnection = null; + WriteLock = null; - public static IDisposable Write(this ReaderWriterLockSlim obj) - { - //if (BaseSqliteRepository.ThreadSafeMode > 0) - //{ - // return new DummyToken(); - //} - return new WriteLockToken(obj); + _disposed = true; } } } diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs index f7743a3c2..3de9d6371 100644 --- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs +++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Threading; using System.Threading.Tasks; @@ -10,9 +12,9 @@ namespace Emby.Server.Implementations.Data public class CleanDatabaseScheduledTask : ILibraryPostScanTask { private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + private readonly ILogger<CleanDatabaseScheduledTask> _logger; - public CleanDatabaseScheduledTask(ILibraryManager libraryManager, ILogger logger) + public CleanDatabaseScheduledTask(ILibraryManager libraryManager, ILogger<CleanDatabaseScheduledTask> logger) { _libraryManager = libraryManager; _logger = logger; @@ -49,7 +51,6 @@ namespace Emby.Server.Implementations.Data _libraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false - }); } diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index b8f1e581a..44dad5b17 100644 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -1,79 +1,82 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Threading; using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { public class ManagedConnection : IDisposable { - private SQLiteDatabaseConnection db; - private readonly bool _closeOnDispose; + private readonly SemaphoreSlim _writeLock; + + private SQLiteDatabaseConnection? _db; + + private bool _disposed = false; - public ManagedConnection(SQLiteDatabaseConnection db, bool closeOnDispose) + public ManagedConnection(SQLiteDatabaseConnection db, SemaphoreSlim writeLock) { - this.db = db; - _closeOnDispose = closeOnDispose; + _db = db; + _writeLock = writeLock; } public IStatement PrepareStatement(string sql) { - return db.PrepareStatement(sql); + return _db.PrepareStatement(sql); } public IEnumerable<IStatement> PrepareAll(string sql) { - return db.PrepareAll(sql); + return _db.PrepareAll(sql); } public void ExecuteAll(string sql) { - db.ExecuteAll(sql); + _db.ExecuteAll(sql); } public void Execute(string sql, params object[] values) { - db.Execute(sql, values); + _db.Execute(sql, values); } public void RunQueries(string[] sql) { - db.RunQueries(sql); + _db.RunQueries(sql); } public void RunInTransaction(Action<IDatabaseConnection> action, TransactionMode mode) { - db.RunInTransaction(action, mode); + _db.RunInTransaction(action, mode); } public T RunInTransaction<T>(Func<IDatabaseConnection, T> action, TransactionMode mode) { - return db.RunInTransaction(action, mode); + return _db.RunInTransaction(action, mode); } - public IEnumerable<IReadOnlyList<IResultSetValue>> Query(string sql) + public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql) { - return db.Query(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); - } - - public void Close() - { - using (db) - { - - } + return _db.Query(sql, values); } public void Dispose() { - if (_closeOnDispose) + if (_disposed) { - Close(); + return; } + + _writeLock.Release(); + + _db = null; // Don't dispose it + _disposed = true; } } } diff --git a/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs b/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs deleted file mode 100644 index 3d60925da..000000000 --- a/Emby.Server.Implementations/Data/SqliteDisplayPreferencesRepository.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Data -{ - /// <summary> - /// Class SQLiteDisplayPreferencesRepository - /// </summary> - public class SqliteDisplayPreferencesRepository : BaseSqliteRepository, IDisplayPreferencesRepository - { - protected IFileSystem FileSystem { get; private set; } - - public SqliteDisplayPreferencesRepository(ILoggerFactory loggerFactory, IJsonSerializer jsonSerializer, IApplicationPaths appPaths, IFileSystem fileSystem) - : base(loggerFactory.CreateLogger(nameof(SqliteDisplayPreferencesRepository))) - { - _jsonSerializer = jsonSerializer; - FileSystem = fileSystem; - DbFilePath = Path.Combine(appPaths.DataPath, "displaypreferences.db"); - } - - /// <summary> - /// Gets the name of the repository - /// </summary> - /// <value>The name.</value> - public string Name => "SQLite"; - - /// <summary> - /// The _json serializer - /// </summary> - private readonly IJsonSerializer _jsonSerializer; - - public void Initialize() - { - try - { - InitializeInternal(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error loading database file. Will reset and retry."); - - FileSystem.DeleteFile(DbFilePath); - - InitializeInternal(); - } - } - - /// <summary> - /// Opens the connection to the database - /// </summary> - /// <returns>Task.</returns> - private void InitializeInternal() - { - using (var connection = CreateConnection()) - { - RunDefaultInitialization(connection); - - string[] queries = { - - "create table if not exists userdisplaypreferences (id GUID NOT NULL, userId GUID NOT NULL, client text NOT NULL, data BLOB NOT NULL)", - "create unique index if not exists userdisplaypreferencesindex on userdisplaypreferences (id, userId, client)" - }; - - connection.RunQueries(queries); - } - } - - /// <summary> - /// Save the display preferences associated with an item in the repo - /// </summary> - /// <param name="displayPreferences">The display preferences.</param> - /// <param name="userId">The user id.</param> - /// <param name="client">The client.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, CancellationToken cancellationToken) - { - if (displayPreferences == null) - { - throw new ArgumentNullException(nameof(displayPreferences)); - } - if (string.IsNullOrEmpty(displayPreferences.Id)) - { - throw new ArgumentNullException(nameof(displayPreferences.Id)); - } - - cancellationToken.ThrowIfCancellationRequested(); - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - SaveDisplayPreferences(displayPreferences, userId, client, db); - }, TransactionMode); - } - } - } - - private void SaveDisplayPreferences(DisplayPreferences displayPreferences, Guid userId, string client, IDatabaseConnection connection) - { - var serialized = _jsonSerializer.SerializeToBytes(displayPreferences); - - using (var statement = connection.PrepareStatement("replace into userdisplaypreferences (id, userid, client, data) values (@id, @userId, @client, @data)")) - { - statement.TryBind("@id", displayPreferences.Id.ToGuidBlob()); - statement.TryBind("@userId", userId.ToGuidBlob()); - statement.TryBind("@client", client); - statement.TryBind("@data", serialized); - - statement.MoveNext(); - } - } - - /// <summary> - /// Save all display preferences associated with a user in the repo - /// </summary> - /// <param name="displayPreferences">The display preferences.</param> - /// <param name="userId">The user id.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public void SaveAllDisplayPreferences(IEnumerable<DisplayPreferences> displayPreferences, Guid userId, CancellationToken cancellationToken) - { - if (displayPreferences == null) - { - throw new ArgumentNullException(nameof(displayPreferences)); - } - - cancellationToken.ThrowIfCancellationRequested(); - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - foreach (var displayPreference in displayPreferences) - { - SaveDisplayPreferences(displayPreference, userId, displayPreference.Client, db); - } - }, TransactionMode); - } - } - } - - /// <summary> - /// Gets the display preferences. - /// </summary> - /// <param name="displayPreferencesId">The display preferences id.</param> - /// <param name="userId">The user id.</param> - /// <param name="client">The client.</param> - /// <returns>Task{DisplayPreferences}.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, Guid userId, string client) - { - if (string.IsNullOrEmpty(displayPreferencesId)) - { - throw new ArgumentNullException(nameof(displayPreferencesId)); - } - - var guidId = displayPreferencesId.GetMD5(); - - using (WriteLock.Read()) - { - using (var connection = CreateConnection(true)) - { - using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where id = @id and userId=@userId and client=@client")) - { - statement.TryBind("@id", guidId.ToGuidBlob()); - statement.TryBind("@userId", userId.ToGuidBlob()); - statement.TryBind("@client", client); - - foreach (var row in statement.ExecuteQuery()) - { - return Get(row); - } - } - - return new DisplayPreferences - { - Id = guidId.ToString("N") - }; - } - } - } - - /// <summary> - /// Gets all display preferences for the given user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <returns>Task{DisplayPreferences}.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public IEnumerable<DisplayPreferences> GetAllDisplayPreferences(Guid userId) - { - var list = new List<DisplayPreferences>(); - - using (WriteLock.Read()) - { - using (var connection = CreateConnection(true)) - { - using (var statement = connection.PrepareStatement("select data from userdisplaypreferences where userId=@userId")) - { - statement.TryBind("@userId", userId.ToGuidBlob()); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(Get(row)); - } - } - } - } - - return list; - } - - private DisplayPreferences Get(IReadOnlyList<IResultSetValue> row) - { - using (var stream = new MemoryStream(row[0].ToBlob())) - { - stream.Position = 0; - return _jsonSerializer.DeserializeFromStream<DisplayPreferences>(stream); - } - } - - public void SaveDisplayPreferences(DisplayPreferences displayPreferences, string userId, string client, CancellationToken cancellationToken) - { - SaveDisplayPreferences(displayPreferences, new Guid(userId), client, cancellationToken); - } - - public DisplayPreferences GetDisplayPreferences(string displayPreferencesId, string userId, string client) - { - return GetDisplayPreferences(displayPreferencesId, new Guid(userId), client); - } - } -} diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index a486cb1a0..381eb92a8 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -1,14 +1,57 @@ +#nullable disable +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; -using System.IO; -using MediaBrowser.Model.Serialization; using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { public static class SqliteExtensions { + private const string DatetimeFormatUtc = "yyyy-MM-dd HH:mm:ss.FFFFFFFK"; + private const string DatetimeFormatLocal = "yyyy-MM-dd HH:mm:ss.FFFFFFF"; + + /// <summary> + /// An array of ISO-8601 DateTime formats that we support parsing. + /// </summary> + private static readonly string[] _datetimeFormats = new string[] + { + "THHmmssK", + "THHmmK", + "HH:mm:ss.FFFFFFFK", + "HH:mm:ssK", + "HH:mmK", + DatetimeFormatUtc, + "yyyy-MM-dd HH:mm:ssK", + "yyyy-MM-dd HH:mmK", + "yyyy-MM-ddTHH:mm:ss.FFFFFFFK", + "yyyy-MM-ddTHH:mmK", + "yyyy-MM-ddTHH:mm:ssK", + "yyyyMMddHHmmssK", + "yyyyMMddHHmmK", + "yyyyMMddTHHmmssFFFFFFFK", + "THHmmss", + "THHmm", + "HH:mm:ss.FFFFFFF", + "HH:mm:ss", + "HH:mm", + DatetimeFormatLocal, + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm", + "yyyy-MM-ddTHH:mm:ss.FFFFFFF", + "yyyy-MM-ddTHH:mm", + "yyyy-MM-ddTHH:mm:ss", + "yyyyMMddHHmmss", + "yyyyMMddHHmm", + "yyyyMMddTHHmmssFFFFFFF", + "yyyy-MM-dd", + "yyyyMMdd", + "yy-MM-dd" + }; + public static void RunQueries(this SQLiteDatabaseConnection connection, string[] queries) { if (queries == null) @@ -18,25 +61,11 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction(conn => { - //foreach (var query in queries) - //{ - // conn.Execute(query); - //} - conn.ExecuteAll(string.Join(";", queries)); + conn.ExecuteAll(string.Join(';', queries)); }); } - public static byte[] ToGuidBlob(this string str) - { - return ToGuidBlob(new Guid(str)); - } - - public static byte[] ToGuidBlob(this Guid guid) - { - return guid.ToByteArray(); - } - - public static Guid ReadGuidFromBlob(this IResultSetValue result) + public static Guid ReadGuidFromBlob(this ResultSetValue result) { return new Guid(result.ToBlob()); } @@ -54,147 +83,161 @@ namespace Emby.Server.Implementations.Data CultureInfo.InvariantCulture); } - private static string GetDateTimeKindFormat( - DateTimeKind kind) - { - return (kind == DateTimeKind.Utc) ? _datetimeFormatUtc : _datetimeFormatLocal; - } + private static string GetDateTimeKindFormat(DateTimeKind kind) + => (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal; - /// <summary> - /// An array of ISO-8601 DateTime formats that we support parsing. - /// </summary> - private static string[] _datetimeFormats = new string[] { - "THHmmssK", - "THHmmK", - "HH:mm:ss.FFFFFFFK", - "HH:mm:ssK", - "HH:mmK", - "yyyy-MM-dd HH:mm:ss.FFFFFFFK", /* NOTE: UTC default (5). */ - "yyyy-MM-dd HH:mm:ssK", - "yyyy-MM-dd HH:mmK", - "yyyy-MM-ddTHH:mm:ss.FFFFFFFK", - "yyyy-MM-ddTHH:mmK", - "yyyy-MM-ddTHH:mm:ssK", - "yyyyMMddHHmmssK", - "yyyyMMddHHmmK", - "yyyyMMddTHHmmssFFFFFFFK", - "THHmmss", - "THHmm", - "HH:mm:ss.FFFFFFF", - "HH:mm:ss", - "HH:mm", - "yyyy-MM-dd HH:mm:ss.FFFFFFF", /* NOTE: Non-UTC default (19). */ - "yyyy-MM-dd HH:mm:ss", - "yyyy-MM-dd HH:mm", - "yyyy-MM-ddTHH:mm:ss.FFFFFFF", - "yyyy-MM-ddTHH:mm", - "yyyy-MM-ddTHH:mm:ss", - "yyyyMMddHHmmss", - "yyyyMMddHHmm", - "yyyyMMddTHHmmssFFFFFFF", - "yyyy-MM-dd", - "yyyyMMdd", - "yy-MM-dd" - }; - - private static string _datetimeFormatUtc = _datetimeFormats[5]; - private static string _datetimeFormatLocal = _datetimeFormats[19]; - - public static DateTime ReadDateTime(this IResultSetValue result) + public static DateTime ReadDateTime(this ResultSetValue result) { var dateText = result.ToString(); return DateTime.ParseExact( - dateText, _datetimeFormats, + dateText, + _datetimeFormats, DateTimeFormatInfo.InvariantInfo, - DateTimeStyles.None).ToUniversalTime(); + DateTimeStyles.AdjustToUniversal); } - 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)) + if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal, out var dateTimeResult)) { - return dateTimeResult.ToUniversalTime(); + result = dateTimeResult; + return true; } - return null; + result = default; + return false; } - /// <summary> - /// Serializes to bytes. - /// </summary> - /// <returns>System.Byte[][].</returns> - /// <exception cref="ArgumentNullException">obj</exception> - public static byte[] SerializeToBytes(this IJsonSerializer json, object obj) + public static bool TryGetGuid(this IReadOnlyList<ResultSetValue> reader, int index, out Guid result) { - if (obj == null) + var item = reader[index]; + if (item.IsDbNull()) { - throw new ArgumentNullException(nameof(obj)); + result = default; + return false; } - using (var stream = new MemoryStream()) - { - json.SerializeToStream(obj, stream); - return stream.ToArray(); - } + result = item.ReadGuidFromBlob(); + return true; } - public static void Attach(ManagedConnection db, string path, string alias) + public static bool IsDbNull(this ResultSetValue result) { - var commandText = string.Format("attach @path as {0};", alias); + return result.SQLiteType == SQLiteType.Null; + } - using (var statement = db.PrepareStatement(commandText)) + public static string GetString(this IReadOnlyList<ResultSetValue> result, int index) + { + return result[index].ToString(); + } + + public static bool TryGetString(this IReadOnlyList<ResultSetValue> reader, int index, out string result) + { + result = null; + var item = reader[index]; + if (item.IsDbNull()) { - statement.TryBind("@path", path); - statement.MoveNext(); + return false; } + + result = item.ToString(); + return true; } - public static bool IsDBNull(this IReadOnlyList<IResultSetValue> result, int index) + public static bool GetBoolean(this IReadOnlyList<ResultSetValue> result, int index) { - return result[index].SQLiteType == SQLiteType.Null; + return result[index].ToBool(); } - public static string GetString(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetBoolean(this IReadOnlyList<ResultSetValue> reader, int index, out bool result) { - return result[index].ToString(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToBool(); + return true; } - public static bool GetBoolean(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetInt32(this IReadOnlyList<ResultSetValue> reader, int index, out int result) { - return result[index].ToBool(); + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToInt(); + return true; } - public static int GetInt32(this IReadOnlyList<IResultSetValue> result, int index) + public static long GetInt64(this IReadOnlyList<ResultSetValue> result, int index) { - return result[index].ToInt(); + return result[index].ToInt64(); } - public static long GetInt64(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetInt64(this IReadOnlyList<ResultSetValue> reader, int index, out long result) { - return result[index].ToInt64(); + 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) + { + var item = reader[index]; + if (item.IsDbNull()) + { + result = default; + return false; + } + + result = item.ToFloat(); + return true; } - public static float GetFloat(this IReadOnlyList<IResultSetValue> result, int index) + public static bool TryGetDouble(this IReadOnlyList<ResultSetValue> reader, int index, out double result) { - return result[index].ToFloat(); + 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 - //if (!name.IndexOf("@", StringComparison.OrdinalIgnoreCase) != 0) - { - throw new Exception("Invalid param name: " + name); - } -#endif + throw new ArgumentException("Invalid param name: " + name, nameof(name)); } public static void TryBind(this IStatement statement, string name, double value) @@ -268,7 +311,9 @@ namespace Emby.Server.Implementations.Data { if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) { - bindParam.Bind(value.ToGuidBlob()); + Span<byte> byteValue = stackalloc byte[16]; + value.TryWriteBytes(byteValue); + bindParam.Bind(byteValue); } else { @@ -300,7 +345,7 @@ namespace Emby.Server.Implementations.Data } } - public static void TryBind(this IStatement statement, string name, byte[] value) + public static void TryBind(this IStatement statement, string name, ReadOnlySpan<byte> value) { if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) { @@ -396,12 +441,11 @@ namespace Emby.Server.Implementations.Data } } - public static IEnumerable<IReadOnlyList<IResultSetValue>> ExecuteQuery( - this IStatement This) + public static IEnumerable<IReadOnlyList<ResultSetValue>> ExecuteQuery(this IStatement statement) { - while (This.MoveNext()) + while (statement.MoveNext()) { - yield return This.Current; + yield return statement.Current; } } } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 6502e4aed..13f1df7c8 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -1,12 +1,21 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; +using System.Buffers.Text; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Runtime.Serialization; 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.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; @@ -15,123 +24,445 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Reflection; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { /// <summary> - /// Class SQLiteItemRepository + /// Class SQLiteItemRepository. /// </summary> public class SqliteItemRepository : BaseSqliteRepository, IItemRepository { + private const string FromText = " from TypedBaseItems A"; + private const string ChaptersTableName = "Chapters2"; + + private const string SaveItemCommandText = + @"replace into TypedBaseItems + (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) + values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; + + private readonly IServerConfigurationManager _config; + private readonly IServerApplicationHost _appHost; + private readonly ILocalizationManager _localization; + // TODO: Remove this dependency. GetImageCacheTag() is the only method used and it can be converted to a static helper method + private readonly IImageProcessor _imageProcessor; + private readonly TypeMapper _typeMapper; + private readonly JsonSerializerOptions _jsonOptions; - /// <summary> - /// Gets the name of the repository - /// </summary> - /// <value>The name.</value> - public string Name => "SQLite"; + private readonly ItemFields[] _allItemFields = Enum.GetValues<ItemFields>(); - /// <summary> - /// Gets the json serializer. - /// </summary> - /// <value>The json serializer.</value> - private readonly IJsonSerializer _jsonSerializer; + private static readonly string[] _retriveItemColumns = + { + "type", + "data", + "StartDate", + "EndDate", + "ChannelId", + "IsMovie", + "IsSeries", + "EpisodeTitle", + "IsRepeat", + "CommunityRating", + "CustomRating", + "IndexNumber", + "IsLocked", + "PreferredMetadataLanguage", + "PreferredMetadataCountryCode", + "Width", + "Height", + "DateLastRefreshed", + "Name", + "Path", + "PremiereDate", + "Overview", + "ParentIndexNumber", + "ProductionYear", + "OfficialRating", + "ForcedSortName", + "RunTimeTicks", + "Size", + "DateCreated", + "DateModified", + "guid", + "Genres", + "ParentId", + "Audio", + "ExternalServiceId", + "IsInMixedFolder", + "DateLastSaved", + "LockedFields", + "Studios", + "Tags", + "TrailerTypes", + "OriginalTitle", + "PrimaryVersionId", + "DateLastMediaAdded", + "Album", + "CriticRating", + "IsVirtualItem", + "SeriesName", + "SeasonName", + "SeasonId", + "SeriesId", + "PresentationUniqueKey", + "InheritedParentalRatingValue", + "ExternalSeriesId", + "Tagline", + "ProviderIds", + "Images", + "ProductionLocations", + "ExtraIds", + "TotalBitrate", + "ExtraType", + "Artists", + "AlbumArtists", + "ExternalId", + "SeriesPresentationUniqueKey", + "ShowId", + "OwnerId" + }; - /// <summary> - /// The _app paths - /// </summary> - private readonly IServerConfigurationManager _config; - private IServerApplicationHost _appHost; + private static readonly string _retriveItemColumnsSelectQuery = $"select {string.Join(',', _retriveItemColumns)} from TypedBaseItems where guid = @guid"; + + private static readonly string[] _mediaStreamSaveColumns = + { + "ItemId", + "StreamIndex", + "StreamType", + "Codec", + "Language", + "ChannelLayout", + "Profile", + "AspectRatio", + "Path", + "IsInterlaced", + "BitRate", + "Channels", + "SampleRate", + "IsDefault", + "IsForced", + "IsExternal", + "Height", + "Width", + "AverageFrameRate", + "RealFrameRate", + "Level", + "PixelFormat", + "BitDepth", + "IsAnamorphic", + "RefFrames", + "CodecTag", + "Comment", + "NalLengthSize", + "IsAvc", + "Title", + "TimeBase", + "CodecTimeBase", + "ColorPrimaries", + "ColorSpace", + "ColorTransfer" + }; + + private static readonly string _mediaStreamSaveColumnsInsertQuery = + $"insert into mediastreams ({string.Join(',', _mediaStreamSaveColumns)}) values "; + + private static readonly string _mediaStreamSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaStreamSaveColumns)} from mediastreams where ItemId=@ItemId"; + + private static readonly string[] _mediaAttachmentSaveColumns = + { + "ItemId", + "AttachmentIndex", + "Codec", + "CodecTag", + "Comment", + "Filename", + "MIMEType" + }; + + private static readonly string _mediaAttachmentSaveColumnsSelectQuery = + $"select {string.Join(',', _mediaAttachmentSaveColumns)} from mediaattachments where ItemId=@ItemId"; + + private static readonly string _mediaAttachmentInsertPrefix; + + private static readonly HashSet<string> _programTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "Program", + "TvChannel", + "LiveTvProgram", + "LiveTvTvChannel" + }; + + private static readonly HashSet<string> _programExcludeParentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "Series", + "Season", + "MusicAlbum", + "MusicArtist", + "PhotoAlbum" + }; + + private static readonly HashSet<string> _serviceTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "TvChannel", + "LiveTvTvChannel" + }; + + private static readonly HashSet<string> _startDateTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "Program", + "LiveTvProgram" + }; + + private static readonly HashSet<string> _seriesTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "Book", + "AudioBook", + "Episode", + "Season" + }; + + private static readonly HashSet<string> _artistExcludeParentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "Series", + "Season", + "PhotoAlbum" + }; + + private static readonly HashSet<string> _artistsTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "Audio", + "MusicAlbum", + "MusicVideo", + "AudioBook", + "AudioPodcast" + }; - public IImageProcessor ImageProcessor { get; set; } + private static readonly Type[] _knownTypes = + { + typeof(LiveTvProgram), + typeof(LiveTvChannel), + typeof(Series), + typeof(Audio), + typeof(MusicAlbum), + typeof(MusicArtist), + typeof(MusicGenre), + typeof(MusicVideo), + typeof(Movie), + typeof(Playlist), + typeof(AudioBook), + typeof(Trailer), + typeof(BoxSet), + typeof(Episode), + typeof(Season), + typeof(Series), + typeof(Book), + typeof(CollectionFolder), + typeof(Folder), + typeof(Genre), + typeof(Person), + typeof(Photo), + typeof(PhotoAlbum), + typeof(Studio), + typeof(UserRootFolder), + typeof(UserView), + typeof(Video), + typeof(Year), + typeof(Channel), + typeof(AggregateFolder) + }; + + private readonly Dictionary<string, string> _types = GetTypeMapDictionary(); + + static SqliteItemRepository() + { + var queryPrefixText = new StringBuilder(); + queryPrefixText.Append("insert into mediaattachments ("); + foreach (var column in _mediaAttachmentSaveColumns) + { + queryPrefixText.Append(column) + .Append(','); + } + + queryPrefixText.Length -= 1; + queryPrefixText.Append(") values "); + _mediaAttachmentInsertPrefix = queryPrefixText.ToString(); + } /// <summary> /// Initializes a new instance of the <see cref="SqliteItemRepository"/> class. /// </summary> + /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{SqliteItemRepository}"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param> + /// <exception cref="ArgumentNullException">config is null.</exception> public SqliteItemRepository( IServerConfigurationManager config, IServerApplicationHost appHost, - IJsonSerializer jsonSerializer, - ILoggerFactory loggerFactory, - IAssemblyInfo assemblyInfo) - : base(loggerFactory.CreateLogger(nameof(SqliteItemRepository))) + ILogger<SqliteItemRepository> logger, + ILocalizationManager localization, + IImageProcessor imageProcessor) + : base(logger) { if (config == null) { throw new ArgumentNullException(nameof(config)); } - if (jsonSerializer == null) - { - throw new ArgumentNullException(nameof(jsonSerializer)); - } - - _appHost = appHost; _config = config; - _jsonSerializer = jsonSerializer; - _typeMapper = new TypeMapper(assemblyInfo); + _appHost = appHost; + _localization = localization; + _imageProcessor = imageProcessor; + + _typeMapper = new TypeMapper(); + _jsonOptions = JsonDefaults.Options; DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db"); } - private const string ChaptersTableName = "Chapters2"; + /// <inheritdoc /> + public string Name => "SQLite"; + /// <inheritdoc /> protected override int? CacheSize => 20000; - protected override bool EnableTempStoreMemory => true; + /// <inheritdoc /> + protected override TempStoreMode TempStore => TempStoreMode.Memory; /// <summary> - /// Opens the connection to the database + /// Opens the connection to the database. /// </summary> + /// <param name="userDataRepo">The user data repository.</param> + /// <param name="userManager">The user manager.</param> public void Initialize(SqliteUserDataRepository userDataRepo, IUserManager userManager) { - using (var connection = CreateConnection()) - { - RunDefaultInitialization(connection); - - const string createMediaStreamsTableCommand + const string CreateMediaStreamsTableCommand = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, PRIMARY KEY (ItemId, StreamIndex))"; + const string CreateMediaAttachmentsTableCommand + = "create table if not exists mediaattachments (ItemId GUID, AttachmentIndex INT, Codec TEXT, CodecTag TEXT NULL, Comment TEXT NULL, Filename TEXT NULL, MIMEType TEXT NULL, PRIMARY KEY (ItemId, AttachmentIndex))"; - string[] queries = { - "PRAGMA locking_mode=EXCLUSIVE", + string[] queries = + { + "PRAGMA locking_mode=EXCLUSIVE", - "create table if not exists TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL)", + "create table if not exists TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL)", - "create table if not exists AncestorIds (ItemId GUID NOT NULL, AncestorId GUID NOT NULL, AncestorIdText TEXT NOT NULL, PRIMARY KEY (ItemId, AncestorId))", - "create index if not exists idx_AncestorIds1 on AncestorIds(AncestorId)", - "create index if not exists idx_AncestorIds5 on AncestorIds(AncestorIdText,ItemId)", + "create table if not exists AncestorIds (ItemId GUID NOT NULL, AncestorId GUID NOT NULL, AncestorIdText TEXT NOT NULL, PRIMARY KEY (ItemId, AncestorId))", + "create index if not exists idx_AncestorIds1 on AncestorIds(AncestorId)", + "create index if not exists idx_AncestorIds5 on AncestorIds(AncestorIdText,ItemId)", - "create table if not exists ItemValues (ItemId GUID NOT NULL, Type INT NOT NULL, Value TEXT NOT NULL, CleanValue TEXT NOT NULL)", + "create table if not exists ItemValues (ItemId GUID NOT NULL, Type INT NOT NULL, Value TEXT NOT NULL, CleanValue TEXT NOT NULL)", - "create table if not exists People (ItemId GUID, Name TEXT NOT NULL, Role TEXT, PersonType TEXT, SortOrder int, ListOrder int)", + "create table if not exists People (ItemId GUID, Name TEXT NOT NULL, Role TEXT, PersonType TEXT, SortOrder int, ListOrder int)", - "drop index if exists idxPeopleItemId", - "create index if not exists idxPeopleItemId1 on People(ItemId,ListOrder)", - "create index if not exists idxPeopleName on People(Name)", + "drop index if exists idxPeopleItemId", + "create index if not exists idxPeopleItemId1 on People(ItemId,ListOrder)", + "create index if not exists idxPeopleName on People(Name)", - "create table if not exists " + ChaptersTableName + " (ItemId GUID, ChapterIndex INT NOT NULL, StartPositionTicks BIGINT NOT NULL, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", + "create table if not exists " + ChaptersTableName + " (ItemId GUID, ChapterIndex INT NOT NULL, StartPositionTicks BIGINT NOT NULL, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", - createMediaStreamsTableCommand, + CreateMediaStreamsTableCommand, + CreateMediaAttachmentsTableCommand, - "pragma shrink_memory" - }; + "pragma shrink_memory" + }; + string[] postQueries = + { + // obsolete + "drop index if exists idx_TypedBaseItems", + "drop index if exists idx_mediastreams", + "drop index if exists idx_mediastreams1", + "drop index if exists idx_" + ChaptersTableName, + "drop index if exists idx_UserDataKeys1", + "drop index if exists idx_UserDataKeys2", + "drop index if exists idx_TypeTopParentId3", + "drop index if exists idx_TypeTopParentId2", + "drop index if exists idx_TypeTopParentId4", + "drop index if exists idx_Type", + "drop index if exists idx_TypeTopParentId", + "drop index if exists idx_GuidType", + "drop index if exists idx_TopParentId", + "drop index if exists idx_TypeTopParentId6", + "drop index if exists idx_ItemValues2", + "drop index if exists Idx_ProviderIds", + "drop index if exists idx_ItemValues3", + "drop index if exists idx_ItemValues4", + "drop index if exists idx_ItemValues5", + "drop index if exists idx_UserDataKeys3", + "drop table if exists UserDataKeys", + "drop table if exists ProviderIds", + "drop index if exists Idx_ProviderIds1", + "drop table if exists Images", + "drop index if exists idx_Images", + "drop index if exists idx_TypeSeriesPresentationUniqueKey", + "drop index if exists idx_SeriesPresentationUniqueKey", + "drop index if exists idx_TypeSeriesPresentationUniqueKey2", + "drop index if exists idx_AncestorIds3", + "drop index if exists idx_AncestorIds4", + "drop index if exists idx_AncestorIds2", + + "create index if not exists idx_PathTypedBaseItems on TypedBaseItems(Path)", + "create index if not exists idx_ParentIdTypedBaseItems on TypedBaseItems(ParentId)", + + "create index if not exists idx_PresentationUniqueKey on TypedBaseItems(PresentationUniqueKey)", + "create index if not exists idx_GuidTypeIsFolderIsVirtualItem on TypedBaseItems(Guid,Type,IsFolder,IsVirtualItem)", + "create index if not exists idx_CleanNameType on TypedBaseItems(CleanName,Type)", + + // covering index + "create index if not exists idx_TopParentIdGuid on TypedBaseItems(TopParentId,Guid)", + + // series + "create index if not exists idx_TypeSeriesPresentationUniqueKey1 on TypedBaseItems(Type,SeriesPresentationUniqueKey,PresentationUniqueKey,SortName)", + + // series counts + // seriesdateplayed sort order + "create index if not exists idx_TypeSeriesPresentationUniqueKey3 on TypedBaseItems(SeriesPresentationUniqueKey,Type,IsFolder,IsVirtualItem)", + + // live tv programs + "create index if not exists idx_TypeTopParentIdStartDate on TypedBaseItems(Type,TopParentId,StartDate)", + + // covering index for getitemvalues + "create index if not exists idx_TypeTopParentIdGuid on TypedBaseItems(Type,TopParentId,Guid)", + + // used by movie suggestions + "create index if not exists idx_TypeTopParentIdGroup on TypedBaseItems(Type,TopParentId,PresentationUniqueKey)", + "create index if not exists idx_TypeTopParentId5 on TypedBaseItems(TopParentId,IsVirtualItem)", + + // latest items + "create index if not exists idx_TypeTopParentId9 on TypedBaseItems(TopParentId,Type,IsVirtualItem,PresentationUniqueKey,DateCreated)", + "create index if not exists idx_TypeTopParentId8 on TypedBaseItems(TopParentId,IsFolder,IsVirtualItem,PresentationUniqueKey,DateCreated)", + + // resume + "create index if not exists idx_TypeTopParentId7 on TypedBaseItems(TopParentId,MediaType,IsVirtualItem,PresentationUniqueKey)", + + // items by name + "create index if not exists idx_ItemValues6 on ItemValues(ItemId,Type,CleanValue)", + "create index if not exists idx_ItemValues7 on ItemValues(Type,CleanValue,ItemId)", + + // Used to update inherited tags + "create index if not exists idx_ItemValues8 on ItemValues(Type, ItemId, Value)", + }; + + using (var connection = GetConnection()) + { connection.RunQueries(queries); - connection.RunInTransaction(db => + connection.RunInTransaction( + db => { var existingColumnNames = GetColumnNames(db, "AncestorIds"); AddColumn(db, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); @@ -233,302 +564,20 @@ namespace Emby.Server.Implementations.Data AddColumn(db, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); AddColumn(db, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); AddColumn(db, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); - }, TransactionMode); - string[] postQueries = - { - // obsolete - "drop index if exists idx_TypedBaseItems", - "drop index if exists idx_mediastreams", - "drop index if exists idx_mediastreams1", - "drop index if exists idx_"+ChaptersTableName, - "drop index if exists idx_UserDataKeys1", - "drop index if exists idx_UserDataKeys2", - "drop index if exists idx_TypeTopParentId3", - "drop index if exists idx_TypeTopParentId2", - "drop index if exists idx_TypeTopParentId4", - "drop index if exists idx_Type", - "drop index if exists idx_TypeTopParentId", - "drop index if exists idx_GuidType", - "drop index if exists idx_TopParentId", - "drop index if exists idx_TypeTopParentId6", - "drop index if exists idx_ItemValues2", - "drop index if exists Idx_ProviderIds", - "drop index if exists idx_ItemValues3", - "drop index if exists idx_ItemValues4", - "drop index if exists idx_ItemValues5", - "drop index if exists idx_UserDataKeys3", - "drop table if exists UserDataKeys", - "drop table if exists ProviderIds", - "drop index if exists Idx_ProviderIds1", - "drop table if exists Images", - "drop index if exists idx_Images", - "drop index if exists idx_TypeSeriesPresentationUniqueKey", - "drop index if exists idx_SeriesPresentationUniqueKey", - "drop index if exists idx_TypeSeriesPresentationUniqueKey2", - "drop index if exists idx_AncestorIds3", - "drop index if exists idx_AncestorIds4", - "drop index if exists idx_AncestorIds2", - - "create index if not exists idx_PathTypedBaseItems on TypedBaseItems(Path)", - "create index if not exists idx_ParentIdTypedBaseItems on TypedBaseItems(ParentId)", - - "create index if not exists idx_PresentationUniqueKey on TypedBaseItems(PresentationUniqueKey)", - "create index if not exists idx_GuidTypeIsFolderIsVirtualItem on TypedBaseItems(Guid,Type,IsFolder,IsVirtualItem)", - "create index if not exists idx_CleanNameType on TypedBaseItems(CleanName,Type)", - - // covering index - "create index if not exists idx_TopParentIdGuid on TypedBaseItems(TopParentId,Guid)", - - // series - "create index if not exists idx_TypeSeriesPresentationUniqueKey1 on TypedBaseItems(Type,SeriesPresentationUniqueKey,PresentationUniqueKey,SortName)", - - // series counts - // seriesdateplayed sort order - "create index if not exists idx_TypeSeriesPresentationUniqueKey3 on TypedBaseItems(SeriesPresentationUniqueKey,Type,IsFolder,IsVirtualItem)", - - // live tv programs - "create index if not exists idx_TypeTopParentIdStartDate on TypedBaseItems(Type,TopParentId,StartDate)", - - // covering index for getitemvalues - "create index if not exists idx_TypeTopParentIdGuid on TypedBaseItems(Type,TopParentId,Guid)", - - // used by movie suggestions - "create index if not exists idx_TypeTopParentIdGroup on TypedBaseItems(Type,TopParentId,PresentationUniqueKey)", - "create index if not exists idx_TypeTopParentId5 on TypedBaseItems(TopParentId,IsVirtualItem)", - - // latest items - "create index if not exists idx_TypeTopParentId9 on TypedBaseItems(TopParentId,Type,IsVirtualItem,PresentationUniqueKey,DateCreated)", - "create index if not exists idx_TypeTopParentId8 on TypedBaseItems(TopParentId,IsFolder,IsVirtualItem,PresentationUniqueKey,DateCreated)", - - // resume - "create index if not exists idx_TypeTopParentId7 on TypedBaseItems(TopParentId,MediaType,IsVirtualItem,PresentationUniqueKey)", - - // items by name - "create index if not exists idx_ItemValues6 on ItemValues(ItemId,Type,CleanValue)", - "create index if not exists idx_ItemValues7 on ItemValues(Type,CleanValue,ItemId)", - - // Used to update inherited tags - "create index if not exists idx_ItemValues8 on ItemValues(Type, ItemId, Value)", - }; - connection.RunQueries(postQueries); } - userDataRepo.Initialize(WriteLock, _connection, userManager); - } - - private static readonly string[] _retriveItemColumns = - { - "type", - "data", - "StartDate", - "EndDate", - "ChannelId", - "IsMovie", - "IsSeries", - "EpisodeTitle", - "IsRepeat", - "CommunityRating", - "CustomRating", - "IndexNumber", - "IsLocked", - "PreferredMetadataLanguage", - "PreferredMetadataCountryCode", - "Width", - "Height", - "DateLastRefreshed", - "Name", - "Path", - "PremiereDate", - "Overview", - "ParentIndexNumber", - "ProductionYear", - "OfficialRating", - "ForcedSortName", - "RunTimeTicks", - "Size", - "DateCreated", - "DateModified", - "guid", - "Genres", - "ParentId", - "Audio", - "ExternalServiceId", - "IsInMixedFolder", - "DateLastSaved", - "LockedFields", - "Studios", - "Tags", - "TrailerTypes", - "OriginalTitle", - "PrimaryVersionId", - "DateLastMediaAdded", - "Album", - "CriticRating", - "IsVirtualItem", - "SeriesName", - "SeasonName", - "SeasonId", - "SeriesId", - "PresentationUniqueKey", - "InheritedParentalRatingValue", - "ExternalSeriesId", - "Tagline", - "ProviderIds", - "Images", - "ProductionLocations", - "ExtraIds", - "TotalBitrate", - "ExtraType", - "Artists", - "AlbumArtists", - "ExternalId", - "SeriesPresentationUniqueKey", - "ShowId", - "OwnerId" - }; - - private static readonly string[] _mediaStreamSaveColumns = - { - "ItemId", - "StreamIndex", - "StreamType", - "Codec", - "Language", - "ChannelLayout", - "Profile", - "AspectRatio", - "Path", - "IsInterlaced", - "BitRate", - "Channels", - "SampleRate", - "IsDefault", - "IsForced", - "IsExternal", - "Height", - "Width", - "AverageFrameRate", - "RealFrameRate", - "Level", - "PixelFormat", - "BitDepth", - "IsAnamorphic", - "RefFrames", - "CodecTag", - "Comment", - "NalLengthSize", - "IsAvc", - "Title", - "TimeBase", - "CodecTimeBase", - "ColorPrimaries", - "ColorSpace", - "ColorTransfer" - }; - - private static string GetSaveItemCommandText() - { - var saveColumns = new [] - { - "guid", - "type", - "data", - "Path", - "StartDate", - "EndDate", - "ChannelId", - "IsMovie", - "IsSeries", - "EpisodeTitle", - "IsRepeat", - "CommunityRating", - "CustomRating", - "IndexNumber", - "IsLocked", - "Name", - "OfficialRating", - "MediaType", - "Overview", - "ParentIndexNumber", - "PremiereDate", - "ProductionYear", - "ParentId", - "Genres", - "InheritedParentalRatingValue", - "SortName", - "ForcedSortName", - "RunTimeTicks", - "Size", - "DateCreated", - "DateModified", - "PreferredMetadataLanguage", - "PreferredMetadataCountryCode", - "Width", - "Height", - "DateLastRefreshed", - "DateLastSaved", - "IsInMixedFolder", - "LockedFields", - "Studios", - "Audio", - "ExternalServiceId", - "Tags", - "IsFolder", - "UnratedType", - "TopParentId", - "TrailerTypes", - "CriticRating", - "CleanName", - "PresentationUniqueKey", - "OriginalTitle", - "PrimaryVersionId", - "DateLastMediaAdded", - "Album", - "IsVirtualItem", - "SeriesName", - "UserDataKey", - "SeasonName", - "SeasonId", - "SeriesId", - "ExternalSeriesId", - "Tagline", - "ProviderIds", - "Images", - "ProductionLocations", - "ExtraIds", - "TotalBitrate", - "ExtraType", - "Artists", - "AlbumArtists", - "ExternalId", - "SeriesPresentationUniqueKey", - "ShowId", - "OwnerId" - }; - - var saveItemCommandCommandText = "replace into TypedBaseItems (" + string.Join(",", saveColumns) + ") values ("; - - for (var i = 0; i < saveColumns.Length; i++) - { - if (i != 0) - { - saveItemCommandCommandText += ","; - } - saveItemCommandCommandText += "@" + saveColumns[i]; - } - saveItemCommandCommandText += ")"; - return saveItemCommandCommandText; + userDataRepo.Initialize(userManager, WriteLock, WriteConnection); } /// <summary> - /// Save a standard item in the repo + /// Save a standard item in the repo. /// </summary> /// <param name="item">The item.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <exception cref="ArgumentNullException">item</exception> + /// <exception cref="ArgumentNullException"><paramref name="item"/> is <c>null</c>.</exception> public void SaveItem(BaseItem item, CancellationToken cancellationToken) { if (item == null) @@ -536,7 +585,7 @@ namespace Emby.Server.Implementations.Data throw new ArgumentNullException(nameof(item)); } - SaveItems(new [] { item }, cancellationToken); + SaveItems(new[] { item }, cancellationToken); } public void SaveImages(BaseItem item) @@ -548,21 +597,19 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); - using (WriteLock.Write()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => + using (var saveImagesStatement = PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) { - using (var saveImagesStatement = PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) - { - saveImagesStatement.TryBind("@Id", item.Id.ToGuidBlob()); - saveImagesStatement.TryBind("@Images", SerializeImages(item)); + saveImagesStatement.TryBind("@Id", item.Id.ToByteArray()); + saveImagesStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); - saveImagesStatement.MoveNext(); - } - }, TransactionMode); - } + saveImagesStatement.MoveNext(); + } + }, TransactionMode); } } @@ -572,9 +619,7 @@ namespace Emby.Server.Implementations.Data /// <param name="items">The items.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <exception cref="ArgumentNullException"> - /// items - /// or - /// cancellationToken + /// <paramref name="items"/> or <paramref name="cancellationToken"/> is <c>null</c>. /// </exception> public void SaveItems(IEnumerable<BaseItem> items, CancellationToken cancellationToken) { @@ -602,24 +647,21 @@ namespace Emby.Server.Implementations.Data tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags)); } - using (WriteLock.Write()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => - { - SaveItemsInTranscation(db, tuples); - - }, TransactionMode); - } + SaveItemsInTranscation(db, tuples); + }, TransactionMode); } } private void SaveItemsInTranscation(IDatabaseConnection db, IEnumerable<(BaseItem, List<Guid>, BaseItem, string, List<string>)> tuples) { - var statements = PrepareAllSafe(db, new string[] + var statements = PrepareAll(db, new string[] { - GetSaveItemCommandText(), + SaveItemCommandText, "delete from AncestorIds where ItemId=@ItemId" }); @@ -671,12 +713,14 @@ namespace Emby.Server.Implementations.Data private void SaveItem(BaseItem item, BaseItem topParent, string userDataKey, IStatement saveItemStatement) { + Type type = item.GetType(); + saveItemStatement.TryBind("@guid", item.Id); - saveItemStatement.TryBind("@type", item.GetType().FullName); + saveItemStatement.TryBind("@type", type.FullName); - if (TypeRequiresDeserialization(item.GetType())) + if (TypeRequiresDeserialization(type)) { - saveItemStatement.TryBind("@data", _jsonSerializer.SerializeToBytes(item)); + saveItemStatement.TryBind("@data", JsonSerializer.SerializeToUtf8Bytes(item, type, _jsonOptions)); } else { @@ -703,7 +747,7 @@ namespace Emby.Server.Implementations.Data saveItemStatement.TryBindNull("@EndDate"); } - saveItemStatement.TryBind("@ChannelId", item.ChannelId.Equals(Guid.Empty) ? null : item.ChannelId.ToString("N")); + saveItemStatement.TryBind("@ChannelId", item.ChannelId.Equals(Guid.Empty) ? null : item.ChannelId.ToString("N", CultureInfo.InvariantCulture)); if (item is IHasProgramAttributes hasProgramAttributes) { @@ -744,7 +788,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 { @@ -774,6 +818,7 @@ namespace Emby.Server.Implementations.Data { saveItemStatement.TryBindNull("@Width"); } + if (item.Height > 0) { saveItemStatement.TryBind("@Height", item.Height); @@ -805,7 +850,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 { @@ -814,7 +859,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 { @@ -841,7 +886,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 { @@ -858,12 +903,12 @@ namespace Emby.Server.Implementations.Data } else { - saveItemStatement.TryBind("@TopParentId", topParent.Id.ToString("N")); + saveItemStatement.TryBind("@TopParentId", topParent.Id.ToString("N", CultureInfo.InvariantCulture)); } if (item is Trailer trailer && trailer.TrailerTypes.Length > 0) { - saveItemStatement.TryBind("@TrailerTypes", string.Join("|", trailer.TrailerTypes)); + saveItemStatement.TryBind("@TrailerTypes", string.Join('|', trailer.TrailerTypes)); } else { @@ -913,6 +958,7 @@ namespace Emby.Server.Implementations.Data { saveItemStatement.TryBindNull("@SeriesName"); } + if (string.IsNullOrWhiteSpace(userDataKey)) { saveItemStatement.TryBindNull("@UserDataKey"); @@ -952,12 +998,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 { @@ -966,7 +1012,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 { @@ -984,18 +1030,20 @@ namespace Emby.Server.Implementations.Data } string artists = null; - if (item is IHasArtist hasArtists && hasArtists.Artists.Length > 0) + if (item is IHasArtist hasArtists && hasArtists.Artists.Count > 0) { - artists = string.Join("|", hasArtists.Artists); + artists = string.Join('|', hasArtists.Artists); } + saveItemStatement.TryBind("@Artists", artists); string albumArtists = null; if (item is IHasAlbumArtist hasAlbumArtists - && hasAlbumArtists.AlbumArtists.Length > 0) + && hasAlbumArtists.AlbumArtists.Count > 0) { - albumArtists = string.Join("|", hasAlbumArtists.AlbumArtists); + albumArtists = string.Join('|', hasAlbumArtists.AlbumArtists); } + saveItemStatement.TryBind("@AlbumArtists", albumArtists); saveItemStatement.TryBind("@ExternalId", item.ExternalId); @@ -1021,10 +1069,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 @@ -1032,50 +1080,46 @@ namespace Emby.Server.Implementations.Data { continue; } - str.Append($"{i.Key}={i.Value}|"); + + str.Append(i.Key) + .Append('=') + .Append(i.Value) + .Append('|'); } if (str.Length == 0) { return null; } + str.Length -= 1; // Remove last | 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; } + StringBuilder str = new StringBuilder(); foreach (var i in images) { @@ -1083,130 +1127,211 @@ namespace Emby.Server.Implementations.Data { continue; } - str.Append(ToValueString(i) + "|"); + + AppendItemImageInfo(str, i); + str.Append('|'); } + str.Length -= 1; // Remove last | 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 string ToValueString(ItemImageInfo image) + private void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) { - var delimeter = "*"; + const char Delimiter = '*'; - var path = image.Path; + var path = image.Path ?? string.Empty; - if (path == null) + bldr.Append(GetPathToSave(path)) + .Append(Delimiter) + .Append(image.DateModified.Ticks) + .Append(Delimiter) + .Append(image.Type) + .Append(Delimiter) + .Append(image.Width) + .Append(Delimiter) + .Append(image.Height); + + var hash = image.BlurHash; + if (!string.IsNullOrEmpty(hash)) { - path = string.Empty; + bldr.Append(Delimiter) + // Replace delimiters with other characters. + // This can be removed when we migrate to a proper DB. + .Append(hash.Replace('*', '/').Replace('|', '\\')); } - - return GetPathToSave(path) + - delimeter + - image.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) + - delimeter + - image.Type + - delimeter + - image.Width.ToString(CultureInfo.InvariantCulture) + - delimeter + - image.Height.ToString(CultureInfo.InvariantCulture); } - 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; + } + + ReadOnlySpan<char> imageType = value[..nextSegment]; - image.Path = RestorePath(parts[0]); + var image = new ItemImageInfo + { + Path = RestorePath(path.ToString()) + }; - if (long.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks)) + if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks) + && ticks >= DateTime.MinValue.Ticks + && ticks <= DateTime.MaxValue.Ticks) { image.DateModified = new DateTime(ticks, DateTimeKind.Utc); } + else + { + return null; + } - if (Enum.TryParse(parts[2], true, out ImageType type)) + if (Enum.TryParse(imageType, true, out ImageType type)) { image.Type = type; } + else + { + return null; + } - 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 (nextSegment < value.Length - 1) + { + 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); + } } return image; } /// <summary> - /// Internal retrieve from items or users table + /// Internal retrieve from items or users table. /// </summary> /// <param name="id">The id.</param> /// <returns>BaseItem.</returns> - /// <exception cref="ArgumentNullException">id</exception> - /// <exception cref="ArgumentException"></exception> + /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception> + /// <exception cref="ArgumentException"><paramr name="id"/> is <seealso cref="Guid.Empty"/>.</exception> public BaseItem RetrieveItem(Guid id) { if (id == Guid.Empty) { - throw new ArgumentException(nameof(id), "Guid can't be empty"); + throw new ArgumentException("Guid can't be empty", nameof(id)); } CheckDisposed(); - using (WriteLock.Read()) + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + using (var statement = PrepareStatement(connection, _retriveItemColumnsSelectQuery)) { - using (var statement = PrepareStatementSafe(connection, "select " + string.Join(",", _retriveItemColumns) + " from TypedBaseItems where guid = @guid")) - { - statement.TryBind("@guid", id); + statement.TryBind("@guid", id); - foreach (var row in statement.ExecuteQuery()) - { - return GetItem(row, new InternalItemsQuery()); - } + foreach (var row in statement.ExecuteQuery()) + { + return GetItem(row, new InternalItemsQuery()); } - - return null; } } + + return null; } private bool TypeRequiresDeserialization(Type type) @@ -1283,12 +1408,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); @@ -1303,18 +1428,13 @@ namespace Emby.Server.Implementations.Data if (TypeRequiresDeserialization(type)) { - using (var stream = new MemoryStream(reader[1].ToBlob())) + try { - stream.Position = 0; - - try - { - item = _jsonSerializer.DeserializeFromStream(stream, type) as BaseItem; - } - catch (SerializationException ex) - { - Logger.LogError(ex, "Error deserializing item"); - } + item = JsonSerializer.Deserialize(reader[1].ToBlob(), type, _jsonOptions) as BaseItem; + } + catch (JsonException ex) + { + Logger.LogError(ex, "Error deserializing item with JSON: {Data}", reader.GetString(1)); } } @@ -1338,55 +1458,57 @@ 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++; if (enableProgramAttributes) { 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 { @@ -1394,182 +1516,157 @@ 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)) + 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 @@ -1577,160 +1674,156 @@ namespace Emby.Server.Implementations.Data { 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<MetadataFields> GetLockedFields(string s) + List<MetadataField> fields = null; + foreach (var i in lockedFields.AsSpan().Split('|')) { - foreach (var i in s.Split(new [] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + if (Enum.TryParse(i, true, out MetadataField parsedValue)) { - if (Enum.TryParse(i, true, out MetadataFields parsedValue)) - { - yield return parsedValue; - } + (fields ??= new List<MetadataField>()).Add(parsedValue); } } - item.LockedFields = GetLockedFields(reader.GetString(index)).ToArray(); + + item.LockedFields = fields?.ToArray() ?? Array.Empty<MetadataField>(); } - 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) + List<TrailerType> types = null; + foreach (var i in trailerTypes.AsSpan().Split('|')) { - foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + if (Enum.TryParse(i, true, out TrailerType parsedValue)) { - if (Enum.TryParse(i, true, out TrailerType parsedValue)) - { - yield return parsedValue; - } + (types ??= new List<TrailerType>()).Add(parsedValue); } } - trailer.TrailerTypes = GetTrailerTypes(reader.GetString(index)).ToArray(); + + trailer.TrailerTypes = types?.ToArray() ?? Array.Empty<TrailerType>(); } } + index++; } 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; } } + index++; 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; } } + index++; if (hasEpisodeAttributes) { 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 { index++; } + index++; } @@ -1739,145 +1832,142 @@ 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; } } + index++; } 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++; } 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; } } + index++; } 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; } @@ -1896,64 +1986,48 @@ namespace Emby.Server.Implementations.Data return result; } - /// <summary> - /// Gets chapters for an item - /// </summary> - /// <param name="item">The item.</param> - /// <returns>IEnumerable{ChapterInfo}.</returns> - /// <exception cref="ArgumentNullException">id</exception> + /// <inheritdoc /> public List<ChapterInfo> GetChapters(BaseItem item) { CheckDisposed(); - using (WriteLock.Read()) + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + var chapters = new List<ChapterInfo>(); + + using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) { - var list = new List<ChapterInfo>(); + statement.TryBind("@ItemId", item.Id); - using (var statement = PrepareStatementSafe(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) + foreach (var row in statement.ExecuteQuery()) { - statement.TryBind("@ItemId", item.Id); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetChapter(row, item)); - } + chapters.Add(GetChapter(row, item)); } - - return list; } + + return chapters; } } - /// <summary> - /// Gets a single chapter for an item - /// </summary> - /// <param name="item">The item.</param> - /// <param name="index">The index.</param> - /// <returns>ChapterInfo.</returns> - /// <exception cref="ArgumentNullException">id</exception> + /// <inheritdoc /> public ChapterInfo GetChapter(BaseItem item, int index) { CheckDisposed(); - using (WriteLock.Read()) + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) { - using (var statement = PrepareStatementSafe(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) - { - statement.TryBind("@ItemId", item.Id); - statement.TryBind("@ChapterIndex", index); + statement.TryBind("@ItemId", item.Id); + statement.TryBind("@ChapterIndex", index); - foreach (var row in statement.ExecuteQuery()) - { - return GetChapter(row, item); - } + foreach (var row in statement.ExecuteQuery()) + { + return GetChapter(row, item); } } } + return null; } @@ -1961,32 +2035,40 @@ namespace Emby.Server.Implementations.Data /// Gets the chapter. /// </summary> /// <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)) { - chapter.ImageTag = ImageProcessor.GetImageCacheTag(item, chapter); + try + { + chapter.ImageTag = _imageProcessor.GetImageCacheTag(item, chapter); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to create image cache tag."); + } } } - if (!reader.IsDBNull(3)) + if (reader.TryReadDateTime(3, out var imageDateModified)) { - chapter.ImageDateModified = reader[3].ReadDateTime(); + chapter.ImageDateModified = imageDateModified; } return chapter; @@ -1995,7 +2077,9 @@ namespace Emby.Server.Implementations.Data /// <summary> /// Saves the chapters. /// </summary> - public void SaveChapters(Guid id, List<ChapterInfo> chapters) + /// <param name="id">The item id.</param> + /// <param name="chapters">The chapters.</param> + public void SaveChapters(Guid id, IReadOnlyList<ChapterInfo> chapters) { CheckDisposed(); @@ -2009,43 +2093,42 @@ namespace Emby.Server.Implementations.Data throw new ArgumentNullException(nameof(chapters)); } - using (WriteLock.Write()) + var idBlob = id.ToByteArray(); + + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => - { - var idBlob = id.ToGuidBlob(); - - // First delete chapters - db.Execute("delete from " + ChaptersTableName + " where ItemId=@ItemId", idBlob); + // First delete chapters + db.Execute("delete from " + ChaptersTableName + " where ItemId=@ItemId", idBlob); - InsertChapters(idBlob, chapters, db); - - }, TransactionMode); - } + InsertChapters(idBlob, chapters, db); + }, TransactionMode); } } - private void InsertChapters(byte[] idBlob, List<ChapterInfo> chapters, IDatabaseConnection db) + private void InsertChapters(byte[] idBlob, IReadOnlyList<ChapterInfo> chapters, IDatabaseConnection db) { var startIndex = 0; var limit = 100; var chapterIndex = 0; + const string StartInsertText = "insert into " + ChaptersTableName + " (ItemId, ChapterIndex, StartPositionTicks, Name, ImagePath, ImageDateModified) values "; + var insertText = new StringBuilder(StartInsertText, 256); + while (startIndex < chapters.Count) { - var insertText = new StringBuilder("insert into " + ChaptersTableName + " (ItemId, ChapterIndex, StartPositionTicks, Name, ImagePath, ImageDateModified) values "); - var endIndex = Math.Min(chapters.Count, startIndex + limit); for (var i = startIndex; i < endIndex; i++) { - insertText.AppendFormat("(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); + insertText.AppendFormat(CultureInfo.InvariantCulture, "(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); } + insertText.Length -= 1; // Remove last , - using (var statement = PrepareStatementSafe(db, insertText.ToString())) + using (var statement = PrepareStatement(db, insertText.ToString())) { statement.TryBind("@ItemId", idBlob); @@ -2069,6 +2152,7 @@ namespace Emby.Server.Implementations.Data } startIndex += limit; + insertText.Length = StartInsertText.Length; } } @@ -2094,31 +2178,6 @@ 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 bool HasField(InternalItemsQuery query, ItemFields name) { switch (name) @@ -2151,23 +2210,6 @@ namespace Emby.Server.Implementations.Data } } - private static readonly HashSet<string> _programExcludeParentTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Series", - "Season", - "MusicAlbum", - "MusicArtist", - "PhotoAlbum" - }; - - private static readonly HashSet<string> _programTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Program", - "TvChannel", - "LiveTvProgram", - "LiveTvTvChannel" - }; - private bool HasProgramAttributes(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2183,12 +2225,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _programTypes.Contains(x)); } - private static readonly HashSet<string> _serviceTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "TvChannel", - "LiveTvTvChannel" - }; - private bool HasServiceName(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2204,12 +2240,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _serviceTypes.Contains(x)); } - private static readonly HashSet<string> _startDateTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Program", - "LiveTvProgram" - }; - private bool HasStartDate(InternalItemsQuery query) { if (_programExcludeParentTypes.Contains(query.ParentType)) @@ -2245,23 +2275,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", - "Season", - "PhotoAlbum" - }; - - private static readonly HashSet<string> _artistsTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Audio", - "MusicAlbum", - "MusicVideo", - "AudioBook", - "AudioPodcast" - }; - private bool HasArtistFields(InternalItemsQuery query) { if (_artistExcludeParentTypes.Contains(query.ParentType)) @@ -2277,15 +2290,6 @@ namespace Emby.Server.Implementations.Data return query.IncludeItemTypes.Any(x => _artistsTypes.Contains(x)); } - private static readonly HashSet<string> _seriesTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "Audio", - "MusicAlbum", - "MusicVideo", - "AudioBook", - "AudioPodcast" - }; - private bool HasSeriesFields(InternalItemsQuery query) { if (string.Equals(query.ParentType, "PhotoAlbum", StringComparison.OrdinalIgnoreCase)) @@ -2301,77 +2305,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) + foreach (var field in _allItemFields) { 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) @@ -2379,15 +2404,15 @@ namespace Emby.Server.Implementations.Data var item = query.SimilarTo; var builder = new StringBuilder(); - builder.Append("("); + builder.Append('('); 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) @@ -2396,12 +2421,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; @@ -2417,7 +2460,7 @@ namespace Emby.Server.Implementations.Data if (!string.IsNullOrEmpty(query.SearchTerm)) { var builder = new StringBuilder(); - builder.Append("("); + builder.Append('('); builder.Append("((CleanName like @SearchTermStartsWith or (OriginalTitle not null and OriginalTitle like @SearchTermStartsWith)) * 10)"); @@ -2428,10 +2471,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) @@ -2451,6 +2492,7 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@SearchTermStartsWith", searchTerm + "%"); } + if (commandText.IndexOf("@SearchTermContains", StringComparison.OrdinalIgnoreCase) != -1) { statement.TryBind("@SearchTermContains", "%" + searchTerm + "%"); @@ -2496,31 +2538,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) @@ -2538,41 +2574,43 @@ 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); } - using (WriteLock.Read()) + var commandText = commandTextBuilder.ToString(); + int count; + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - using (var statement = PrepareStatementSafe(connection, commandText)) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - var count = statement.ExecuteQuery().SelectScalarInt().First(); - LogQueryTime("GetCount", commandText, now); - return count; - } + count = statement.ExecuteQuery().SelectScalarInt().First(); } - } + + LogQueryTime("GetCount", commandText, now); + return count; } public List<BaseItem> GetItemList(InternalItemsQuery query) @@ -2592,20 +2630,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) { @@ -2613,99 +2654,96 @@ 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); } } - using (WriteLock.Read()) + var commandText = commandTextBuilder.ToString(); + var items = new List<BaseItem>(); + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - var list = new List<BaseItem>(); - - using (var statement = PrepareStatementSafe(connection, commandText)) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasServiceName = HasServiceName(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); + var hasEpisodeAttributes = HasEpisodeAttributes(query); + var hasServiceName = HasServiceName(query); + var hasProgramAttributes = HasProgramAttributes(query); + var hasStartDate = HasStartDate(query); + var hasTrailerTypes = HasTrailerTypes(query); + var hasArtistFields = HasArtistFields(query); + var hasSeriesFields = HasSeriesFields(query); - foreach (var row in statement.ExecuteQuery()) + foreach (var row in statement.ExecuteQuery()) + { + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + if (item != null) { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); - if (item != null) - { - list.Add(item); - } + items.Add(item); } } + } + + // Hack for right now since we currently don't support filtering out these duplicates within a query + if (query.EnableGroupByMetadataKey) + { + var limit = query.Limit ?? int.MaxValue; + limit -= 4; + var newList = new List<BaseItem>(); - // Hack for right now since we currently don't support filtering out these duplicates within a query - if (query.EnableGroupByMetadataKey) + foreach (var item in items) { - var limit = query.Limit ?? int.MaxValue; - limit -= 4; - var newList = new List<BaseItem>(); + AddItem(newList, item); - foreach (var item in list) + if (newList.Count >= limit) { - AddItem(newList, item); - - if (newList.Count >= limit) - { - break; - } + break; } - - list = newList; } - LogQueryTime("GetItemList", commandText, now); - - return list; + items = newList; } } + + LogQueryTime("GetItemList", commandText, now); + + return items; } private string FixUnicodeChars(string buffer) { - if (buffer.IndexOf('\u2013') > -1) buffer = buffer.Replace('\u2013', '-'); // en dash - if (buffer.IndexOf('\u2014') > -1) buffer = buffer.Replace('\u2014', '-'); // em dash - if (buffer.IndexOf('\u2015') > -1) buffer = buffer.Replace('\u2015', '-'); // horizontal bar - if (buffer.IndexOf('\u2017') > -1) buffer = buffer.Replace('\u2017', '_'); // double low line - if (buffer.IndexOf('\u2018') > -1) buffer = buffer.Replace('\u2018', '\''); // left single quotation mark - if (buffer.IndexOf('\u2019') > -1) buffer = buffer.Replace('\u2019', '\''); // right single quotation mark - if (buffer.IndexOf('\u201a') > -1) buffer = buffer.Replace('\u201a', ','); // single low-9 quotation mark - if (buffer.IndexOf('\u201b') > -1) buffer = buffer.Replace('\u201b', '\''); // single high-reversed-9 quotation mark - if (buffer.IndexOf('\u201c') > -1) buffer = buffer.Replace('\u201c', '\"'); // left double quotation mark - if (buffer.IndexOf('\u201d') > -1) buffer = buffer.Replace('\u201d', '\"'); // right double quotation mark - if (buffer.IndexOf('\u201e') > -1) buffer = buffer.Replace('\u201e', '\"'); // double low-9 quotation mark - if (buffer.IndexOf('\u2026') > -1) buffer = buffer.Replace("\u2026", "..."); // horizontal ellipsis - if (buffer.IndexOf('\u2032') > -1) buffer = buffer.Replace('\u2032', '\''); // prime - if (buffer.IndexOf('\u2033') > -1) buffer = buffer.Replace('\u2033', '\"'); // double prime - if (buffer.IndexOf('\u0060') > -1) buffer = buffer.Replace('\u0060', '\''); // grave accent - if (buffer.IndexOf('\u00B4') > -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) @@ -2716,7 +2754,7 @@ namespace Emby.Server.Implementations.Data foreach (var providerId in newItem.ProviderIds) { - if (providerId.Key == MetadataProviders.TmdbCollection.ToString()) + if (providerId.Key == MetadataProvider.TmdbCollection.ToString()) { continue; } @@ -2727,6 +2765,7 @@ namespace Emby.Server.Implementations.Data { items[i] = newItem; } + return; } } @@ -2739,15 +2778,16 @@ namespace Emby.Server.Implementations.Data { var elapsed = (DateTime.UtcNow - startDate).TotalMilliseconds; - int slowThreshold = 100; - #if DEBUG - slowThreshold = 10; + const int SlowThreshold = 100; +#else + const int SlowThreshold = 10; #endif - if (elapsed >= slowThreshold) + if (elapsed >= SlowThreshold) { - Logger.LogWarning("{0} query time (slow): {1}ms. Query: {2}", + Logger.LogDebug( + "{Method} query time (slow): {ElapsedMs}ms. Query: {Query}", methodName, elapsed, commandText); @@ -2768,35 +2808,40 @@ namespace Emby.Server.Implementations.Data var returnList = GetItemList(query); return new QueryResult<BaseItem> { - Items = returnList.ToArray(), + Items = returnList, TotalRecordCount = returnList.Count }; } var now = DateTime.UtcNow; - var list = new List<BaseItem>(); - // Hack for right now since we currently don't support filtering out these duplicates within a query if (query.Limit.HasValue && query.EnableGroupByMetadataKey) { 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) { @@ -2804,158 +2849,170 @@ 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(); } - using (WriteLock.Read()) + var list = new List<BaseItem>(); + var result = new QueryResult<BaseItem>(); + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + connection.RunInTransaction( + db => { - return connection.RunInTransaction(db => - { - var result = new QueryResult<BaseItem>(); - var statements = PrepareAllSafe(db, statementTexts); + var itemQueryStatement = PrepareStatement(db, itemQuery); + var totalRecordCountQueryStatement = PrepareStatement(db, totalRecordCountQuery); - if (!isReturningZeroItems) + if (!isReturningZeroItems) + { + using (var statement = itemQueryStatement) { - using (var statement = statements[0]) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasServiceName = HasServiceName(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); + var hasEpisodeAttributes = HasEpisodeAttributes(query); + var hasServiceName = HasServiceName(query); + var hasProgramAttributes = HasProgramAttributes(query); + var hasStartDate = HasStartDate(query); + var hasTrailerTypes = HasTrailerTypes(query); + var hasArtistFields = HasArtistFields(query); + var hasSeriesFields = HasSeriesFields(query); - foreach (var row in statement.ExecuteQuery()) + foreach (var row in statement.ExecuteQuery()) + { + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + if (item != null) { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); - if (item != null) - { - list.Add(item); - } + list.Add(item); } } } - if (query.EnableTotalRecordCount) + LogQueryTime("GetItems.ItemQuery", itemQuery, now); + } + + now = DateTime.UtcNow; + if (query.EnableTotalRecordCount) + { + using (var statement = totalRecordCountQueryStatement) { - using (var statement = statements[statements.Count - 1]) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); - } + result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); } - LogQueryTime("GetItems", commandText, now); - - result.Items = list.ToArray(); - return result; - - }, ReadTransactionMode); - } + LogQueryTime("GetItems.TotalRecordCount", totalRecordCountQuery, now); + } + }, ReadTransactionMode); } + + result.Items = list; + return result; } private string GetOrderByText(InternalItemsQuery query) { - if (string.IsNullOrEmpty(query.SearchTerm)) - { - int oldLen = query.OrderBy.Length; + var orderBy = query.OrderBy; + bool hasSimilar = query.SimilarTo != null; + bool hasSearch = !string.IsNullOrEmpty(query.SearchTerm); - if (query.SimilarTo != null && oldLen == 0) - { - var arr = new (string, SortOrder)[oldLen + 2]; - query.OrderBy.CopyTo(arr, 0); - arr[oldLen] = ("SimilarityScore", SortOrder.Descending); - arr[oldLen + 1] = (ItemSortBy.Random, SortOrder.Ascending); - query.OrderBy = arr; - } - } - else + if (hasSimilar || hasSearch) { - query.OrderBy = new [] + List<(string, SortOrder)> prepend = new List<(string, SortOrder)>(4); + if (hasSearch) { - ("SearchScore", SortOrder.Descending), - (ItemSortBy.SortName, SortOrder.Ascending) - }; - } + prepend.Add(("SearchScore", SortOrder.Descending)); + prepend.Add((ItemSortBy.SortName, SortOrder.Ascending)); + } - var orderBy = query.OrderBy; + if (hasSimilar) + { + prepend.Add(("SimilarityScore", SortOrder.Descending)); + prepend.Add((ItemSortBy.Random, SortOrder.Ascending)); + } - if (orderBy.Length == 0) + var arr = new (string, SortOrder)[prepend.Count + orderBy.Count]; + prepend.CopyTo(arr, 0); + orderBy.CopyTo(arr, prepend.Count); + orderBy = query.OrderBy = arr; + } + else if (orderBy.Count == 0) { 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); - var columnAscending = i.Item2 == SortOrder.Ascending; - const bool enableOrderInversion = false; - if (columnMap.Item2 && enableOrderInversion) - { - columnAscending = !columnAscending; - } - var sortOrder = columnAscending ? "ASC" : "DESC"; + var sortOrder = i.Item2 == SortOrder.Ascending ? "ASC" : "DESC"; return columnMap.Item1 + " " + sortOrder; })); @@ -3048,19 +3105,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) { @@ -3068,45 +3128,43 @@ 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); } } - using (WriteLock.Read()) + var commandText = commandTextBuilder.ToString(); + var list = new List<Guid>(); + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - var list = new List<Guid>(); - - using (var statement = PrepareStatementSafe(connection, commandText)) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row[0].ReadGuidFromBlob()); - } + foreach (var row in statement.ExecuteQuery()) + { + list.Add(row[0].ReadGuidFromBlob()); } - - LogQueryTime("GetItemList", commandText, now); - - return list; } } + + LogQueryTime("GetItemList", commandText, now); + return list; } public List<Tuple<Guid, string>> GetItemIdsWithPath(InternalItemsQuery query) @@ -3120,7 +3178,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) @@ -3146,39 +3206,33 @@ namespace Emby.Server.Implementations.Data } } - using (WriteLock.Read()) + var list = new List<Tuple<Guid, string>>(); + using (var connection = GetConnection(true)) { - var list = new List<Tuple<Guid, string>>(); - using (var connection = CreateConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - using (var statement = PrepareStatementSafe(connection, commandText)) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - foreach (var row in statement.ExecuteQuery()) - { - var id = row.GetGuid(0); - string path = null; + foreach (var row in statement.ExecuteQuery()) + { + var id = row.GetGuid(0); - if (!row.IsDBNull(1)) - { - path = row.GetString(1); - } - list.Add(new Tuple<Guid, string>(id, path)); - } + row.TryGetString(1, out var path); + + list.Add(new Tuple<Guid, string>(id, path)); } } + } - LogQueryTime("GetItemIdsWithPath", commandText, now); + LogQueryTime("GetItemIdsWithPath", commandText, now); - return list; - } + return list; } public QueryResult<Guid> GetItemIds(InternalItemsQuery query) @@ -3195,16 +3249,18 @@ namespace Emby.Server.Implementations.Data var returnList = GetItemIdsList(query); return new QueryResult<Guid> { - Items = returnList.ToArray(), + Items = returnList, TotalRecordCount = returnList.Count }; } 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); @@ -3232,7 +3288,6 @@ namespace Emby.Server.Implementations.Data } } - var list = new List<Guid>(); var isReturningZeroItems = query.Limit.HasValue && query.Limit <= 0; var statementTexts = new List<string>(); @@ -3240,87 +3295,89 @@ namespace Emby.Server.Implementations.Data { statementTexts.Add(commandText); } + if (query.EnableTotalRecordCount) { 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); } - using (WriteLock.Read()) + var list = new List<Guid>(); + var result = new QueryResult<Guid>(); + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + connection.RunInTransaction( + db => { - return connection.RunInTransaction(db => - { - var result = new QueryResult<Guid>(); - - var statements = PrepareAllSafe(db, statementTexts); + var statements = PrepareAll(db, statementTexts); - if (!isReturningZeroItems) + if (!isReturningZeroItems) + { + using (var statement = statements[0]) { - using (var statement = statements[0]) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row[0].ReadGuidFromBlob()); - } + foreach (var row in statement.ExecuteQuery()) + { + list.Add(row[0].ReadGuidFromBlob()); } } + } - if (query.EnableTotalRecordCount) + if (query.EnableTotalRecordCount) + { + using (var statement = statements[statements.Length - 1]) { - using (var statement = statements[statements.Count - 1]) + if (EnableJoinUserData(query)) { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); - } + result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); } + } + }, ReadTransactionMode); + } - LogQueryTime("GetItemIds", commandText, now); - - result.Items = list.ToArray(); - return result; + LogQueryTime("GetItemIds", commandText, now); - }, ReadTransactionMode); - } - } + result.Items = list; + return result; } private bool IsAlphaNumeric(string str) @@ -3332,7 +3389,7 @@ namespace Emby.Server.Implementations.Data for (int i = 0; i < str.Length; i++) { - if (!(char.IsLetter(str[i])) && (!(char.IsNumber(str[i])))) + if (!char.IsLetter(str[i]) && !char.IsNumber(str[i])) { return false; } @@ -3356,7 +3413,7 @@ namespace Emby.Server.Implementations.Data return IsAlphaNumeric(value); } - private List<string> GetWhereClauses(InternalItemsQuery query, IStatement statement, string paramSuffix = "") + private List<string> GetWhereClauses(InternalItemsQuery query, IStatement statement) { if (query.IsResumable ?? false) { @@ -3368,27 +3425,27 @@ namespace Emby.Server.Implementations.Data if (query.IsHD.HasValue) { - var threshold = 1200; + const int Threshold = 1200; if (query.IsHD.Value) { - minWidth = threshold; + minWidth = Threshold; } else { - maxWidth = threshold - 1; + maxWidth = Threshold - 1; } } if (query.Is4K.HasValue) { - var threshold = 3800; + const int Threshold = 3800; if (query.Is4K.Value) { - minWidth = threshold; + minWidth = Threshold; } else { - maxWidth = threshold - 1; + maxWidth = Threshold - 1; } } @@ -3397,93 +3454,61 @@ namespace Emby.Server.Implementations.Data if (minWidth.HasValue) { whereClauses.Add("Width>=@MinWidth"); - if (statement != null) - { - statement.TryBind("@MinWidth", minWidth); - } + statement?.TryBind("@MinWidth", minWidth); } + if (query.MinHeight.HasValue) { whereClauses.Add("Height>=@MinHeight"); - if (statement != null) - { - statement.TryBind("@MinHeight", query.MinHeight); - } + statement?.TryBind("@MinHeight", query.MinHeight); } + if (maxWidth.HasValue) { whereClauses.Add("Width<=@MaxWidth"); - if (statement != null) - { - statement.TryBind("@MaxWidth", maxWidth); - } + statement?.TryBind("@MaxWidth", maxWidth); } + if (query.MaxHeight.HasValue) { whereClauses.Add("Height<=@MaxHeight"); - if (statement != null) - { - statement.TryBind("@MaxHeight", query.MaxHeight); - } + statement?.TryBind("@MaxHeight", query.MaxHeight); } if (query.IsLocked.HasValue) { whereClauses.Add("IsLocked=@IsLocked"); - if (statement != null) - { - statement.TryBind("@IsLocked", query.IsLocked); - } + statement?.TryBind("@IsLocked", query.IsLocked); } var tags = query.Tags.ToList(); var excludeTags = query.ExcludeTags.ToList(); - if (query.IsMovie ?? false) + if (query.IsMovie == true) { - var alternateTypes = new List<string>(); - if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(typeof(Movie).Name)) - { - alternateTypes.Add(typeof(Movie).FullName); - } - if (query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(typeof(Trailer).Name)) - { - alternateTypes.Add(typeof(Trailer).FullName); - } - - var programAttribtues = new List<string>(); - if (alternateTypes.Count == 0) + if (query.IncludeItemTypes.Length == 0 + || query.IncludeItemTypes.Contains(nameof(Movie)) + || query.IncludeItemTypes.Contains(nameof(Trailer))) { - programAttribtues.Add("IsMovie=@IsMovie"); + whereClauses.Add("(IsMovie is null OR IsMovie=@IsMovie)"); } else { - programAttribtues.Add("(IsMovie is null OR IsMovie=@IsMovie)"); + whereClauses.Add("IsMovie=@IsMovie"); } - if (statement != null) - { - statement.TryBind("@IsMovie", true); - } - - whereClauses.Add("(" + string.Join(" OR ", programAttribtues) + ")"); + statement?.TryBind("@IsMovie", true); } else if (query.IsMovie.HasValue) { whereClauses.Add("IsMovie=@IsMovie"); - if (statement != null) - { - statement.TryBind("@IsMovie", query.IsMovie); - } + statement?.TryBind("@IsMovie", query.IsMovie); } if (query.IsSeries.HasValue) { whereClauses.Add("IsSeries=@IsSeries"); - if (statement != null) - { - statement.TryBind("@IsSeries", query.IsSeries); - } + statement?.TryBind("@IsSeries", query.IsSeries); } if (query.IsSports.HasValue) @@ -3535,155 +3560,111 @@ namespace Emby.Server.Implementations.Data if (query.IsFolder.HasValue) { whereClauses.Add("IsFolder=@IsFolder"); - if (statement != null) - { - statement.TryBind("@IsFolder", query.IsFolder); - } - } - - var includeTypes = query.IncludeItemTypes.SelectMany(MapIncludeItemTypes).ToArray(); - if (includeTypes.Length == 1) - { - whereClauses.Add("type=@type"); - if (statement != null) - { - statement.TryBind("@type", includeTypes[0]); - } - } - else if (includeTypes.Length > 1) - { - var inClause = string.Join(",", includeTypes.Select(i => "'" + i + "'")); - whereClauses.Add($"type in ({inClause})"); + statement?.TryBind("@IsFolder", query.IsFolder); } + 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"); - if (statement != null) - { - statement.TryBind("@type", excludeTypes[0]); - } + statement?.TryBind("@type", excludeTypes[0]); } 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})"); } } + else if (includeTypes.Length == 1) + { + whereClauses.Add("type=@type"); + statement?.TryBind("@type", includeTypes[0]); + } + else if (includeTypes.Length > 1) + { + 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"); - if (statement != null) - { - statement.TryBind("@ChannelId", query.ChannelIds[0].ToString("N")); - } + 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") + "'")); + var inClause = string.Join(',', query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); whereClauses.Add($"ChannelId in ({inClause})"); } if (!query.ParentId.Equals(Guid.Empty)) { whereClauses.Add("ParentId=@ParentId"); - if (statement != null) - { - statement.TryBind("@ParentId", query.ParentId); - } + statement?.TryBind("@ParentId", query.ParentId); } if (!string.IsNullOrWhiteSpace(query.Path)) { whereClauses.Add("Path=@Path"); - if (statement != null) - { - statement.TryBind("@Path", GetPathToSave(query.Path)); - } + statement?.TryBind("@Path", GetPathToSave(query.Path)); } if (!string.IsNullOrWhiteSpace(query.PresentationUniqueKey)) { whereClauses.Add("PresentationUniqueKey=@PresentationUniqueKey"); - if (statement != null) - { - statement.TryBind("@PresentationUniqueKey", query.PresentationUniqueKey); - } + statement?.TryBind("@PresentationUniqueKey", query.PresentationUniqueKey); } if (query.MinCommunityRating.HasValue) { whereClauses.Add("CommunityRating>=@MinCommunityRating"); - if (statement != null) - { - statement.TryBind("@MinCommunityRating", query.MinCommunityRating.Value); - } + statement?.TryBind("@MinCommunityRating", query.MinCommunityRating.Value); } if (query.MinIndexNumber.HasValue) { whereClauses.Add("IndexNumber>=@MinIndexNumber"); - if (statement != null) - { - statement.TryBind("@MinIndexNumber", query.MinIndexNumber.Value); - } + statement?.TryBind("@MinIndexNumber", query.MinIndexNumber.Value); } if (query.MinDateCreated.HasValue) { whereClauses.Add("DateCreated>=@MinDateCreated"); - if (statement != null) - { - statement.TryBind("@MinDateCreated", query.MinDateCreated.Value); - } + statement?.TryBind("@MinDateCreated", query.MinDateCreated.Value); } if (query.MinDateLastSaved.HasValue) { whereClauses.Add("(DateLastSaved not null and DateLastSaved>=@MinDateLastSavedForUser)"); - if (statement != null) - { - statement.TryBind("@MinDateLastSaved", query.MinDateLastSaved.Value); - } + statement?.TryBind("@MinDateLastSaved", query.MinDateLastSaved.Value); } if (query.MinDateLastSavedForUser.HasValue) { whereClauses.Add("(DateLastSaved not null and DateLastSaved>=@MinDateLastSavedForUser)"); - if (statement != null) - { - statement.TryBind("@MinDateLastSavedForUser", query.MinDateLastSavedForUser.Value); - } + statement?.TryBind("@MinDateLastSavedForUser", query.MinDateLastSavedForUser.Value); } if (query.IndexNumber.HasValue) { whereClauses.Add("IndexNumber=@IndexNumber"); - if (statement != null) - { - statement.TryBind("@IndexNumber", query.IndexNumber.Value); - } + statement?.TryBind("@IndexNumber", query.IndexNumber.Value); } + if (query.ParentIndexNumber.HasValue) { whereClauses.Add("ParentIndexNumber=@ParentIndexNumber"); - if (statement != null) - { - statement.TryBind("@ParentIndexNumber", query.ParentIndexNumber.Value); - } + statement?.TryBind("@ParentIndexNumber", query.ParentIndexNumber.Value); } + if (query.ParentIndexNumberNotEquals.HasValue) { whereClauses.Add("(ParentIndexNumber<>@ParentIndexNumberNotEquals or ParentIndexNumber is null)"); - if (statement != null) - { - statement.TryBind("@ParentIndexNumberNotEquals", query.ParentIndexNumberNotEquals.Value); - } + statement?.TryBind("@ParentIndexNumberNotEquals", query.ParentIndexNumberNotEquals.Value); } var minEndDate = query.MinEndDate; @@ -3704,73 +3685,64 @@ namespace Emby.Server.Implementations.Data if (minEndDate.HasValue) { whereClauses.Add("EndDate>=@MinEndDate"); - if (statement != null) - { - statement.TryBind("@MinEndDate", minEndDate.Value); - } + statement?.TryBind("@MinEndDate", minEndDate.Value); } if (maxEndDate.HasValue) { whereClauses.Add("EndDate<=@MaxEndDate"); - if (statement != null) - { - statement.TryBind("@MaxEndDate", maxEndDate.Value); - } + statement?.TryBind("@MaxEndDate", maxEndDate.Value); } if (query.MinStartDate.HasValue) { whereClauses.Add("StartDate>=@MinStartDate"); - if (statement != null) - { - statement.TryBind("@MinStartDate", query.MinStartDate.Value); - } + statement?.TryBind("@MinStartDate", query.MinStartDate.Value); } if (query.MaxStartDate.HasValue) { whereClauses.Add("StartDate<=@MaxStartDate"); - if (statement != null) - { - statement.TryBind("@MaxStartDate", query.MaxStartDate.Value); - } + statement?.TryBind("@MaxStartDate", query.MaxStartDate.Value); } if (query.MinPremiereDate.HasValue) { whereClauses.Add("PremiereDate>=@MinPremiereDate"); - if (statement != null) - { - statement.TryBind("@MinPremiereDate", query.MinPremiereDate.Value); - } + statement?.TryBind("@MinPremiereDate", query.MinPremiereDate.Value); } + if (query.MaxPremiereDate.HasValue) { whereClauses.Add("PremiereDate<=@MaxPremiereDate"); - if (statement != null) - { - statement.TryBind("@MaxPremiereDate", query.MaxPremiereDate.Value); - } + statement?.TryBind("@MaxPremiereDate", query.MaxPremiereDate.Value); } - if (query.TrailerTypes.Length > 0) + StringBuilder clauseBuilder = new StringBuilder(); + const string Or = " OR "; + + var trailerTypes = query.TrailerTypes; + int trailerTypesLen = trailerTypes.Length; + if (trailerTypesLen > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var type in query.TrailerTypes) - { - var paramName = "@TrailerTypes" + index; + clauseBuilder.Append('('); - clauses.Add("TrailerTypes like " + paramName); - if (statement != null) - { - statement.TryBind(paramName, "%" + type + "%"); - } - index++; + for (int i = 0; i < trailerTypesLen; i++) + { + var paramName = "@TrailerTypes" + i; + clauseBuilder.Append("TrailerTypes like ") + .Append(paramName) + .Append(Or); + statement?.TryBind(paramName, "%" + trailerTypes[i] + "%"); } - 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 (query.IsAiring.HasValue) @@ -3778,93 +3750,77 @@ namespace Emby.Server.Implementations.Data if (query.IsAiring.Value) { whereClauses.Add("StartDate<=@MaxStartDate"); - if (statement != null) - { - statement.TryBind("@MaxStartDate", DateTime.UtcNow); - } + statement?.TryBind("@MaxStartDate", DateTime.UtcNow); whereClauses.Add("EndDate>=@MinEndDate"); - if (statement != null) - { - statement.TryBind("@MinEndDate", DateTime.UtcNow); - } + statement?.TryBind("@MinEndDate", DateTime.UtcNow); } else { whereClauses.Add("(StartDate>@IsAiringDate OR EndDate < @IsAiringDate)"); - if (statement != null) - { - statement.TryBind("@IsAiringDate", DateTime.UtcNow); - } + statement?.TryBind("@IsAiringDate", DateTime.UtcNow); } } - 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) - { - var paramName = "@PersonId" + index; + clauseBuilder.Append('('); - clauses.Add("(guid in (select itemid from People where Name = (select Name from TypedBaseItems where guid=" + paramName + ")))"); + Span<byte> idBytes = stackalloc byte[16]; + for (int i = 0; i < personIdsLen; i++) + { + string paramName = "@PersonId" + i; + clauseBuilder.Append("(guid in (select itemid from People where Name = (select Name from TypedBaseItems where guid=") + .Append(paramName) + .Append("))) OR "); if (statement != null) { - statement.TryBind(paramName, personId.ToGuidBlob()); + query.PersonIds[i].TryWriteBytes(idBytes); + statement.TryBind(paramName, idBytes); } - index++; } - 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)) { whereClauses.Add("Guid in (select ItemId from People where Name=@PersonName)"); - if (statement != null) - { - statement.TryBind("@PersonName", query.Person); - } + statement?.TryBind("@PersonName", query.Person); } if (!string.IsNullOrWhiteSpace(query.MinSortName)) { whereClauses.Add("SortName>=@MinSortName"); - if (statement != null) - { - statement.TryBind("@MinSortName", query.MinSortName); - } + statement?.TryBind("@MinSortName", query.MinSortName); } if (!string.IsNullOrWhiteSpace(query.ExternalSeriesId)) { whereClauses.Add("ExternalSeriesId=@ExternalSeriesId"); - if (statement != null) - { - statement.TryBind("@ExternalSeriesId", query.ExternalSeriesId); - } + statement?.TryBind("@ExternalSeriesId", query.ExternalSeriesId); } if (!string.IsNullOrWhiteSpace(query.ExternalId)) { whereClauses.Add("ExternalId=@ExternalId"); - if (statement != null) - { - statement.TryBind("@ExternalId", query.ExternalId); - } + statement?.TryBind("@ExternalId", query.ExternalId); } if (!string.IsNullOrWhiteSpace(query.Name)) { whereClauses.Add("CleanName=@Name"); - - if (statement != null) - { - statement.TryBind("@Name", GetCleanValue(query.Name)); - } + statement?.TryBind("@Name", GetCleanValue(query.Name)); } // These are the same, for now @@ -3883,28 +3839,21 @@ namespace Emby.Server.Implementations.Data if (!string.IsNullOrWhiteSpace(query.NameStartsWith)) { whereClauses.Add("SortName like @NameStartsWith"); - if (statement != null) - { - statement.TryBind("@NameStartsWith", query.NameStartsWith + "%"); - } + statement?.TryBind("@NameStartsWith", query.NameStartsWith + "%"); } + if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater)) { whereClauses.Add("SortName >= @NameStartsWithOrGreater"); // lowercase this because SortName is stored as lowercase - if (statement != null) - { - statement.TryBind("@NameStartsWithOrGreater", query.NameStartsWithOrGreater.ToLowerInvariant()); - } + statement?.TryBind("@NameStartsWithOrGreater", query.NameStartsWithOrGreater.ToLowerInvariant()); } + if (!string.IsNullOrWhiteSpace(query.NameLessThan)) { whereClauses.Add("SortName < @NameLessThan"); // lowercase this because SortName is stored as lowercase - if (statement != null) - { - statement.TryBind("@NameLessThan", query.NameLessThan.ToLowerInvariant()); - } + statement?.TryBind("@NameLessThan", query.NameLessThan.ToLowerInvariant()); } if (query.ImageTypes.Length > 0) @@ -3920,18 +3869,12 @@ namespace Emby.Server.Implementations.Data if (query.IsLiked.Value) { whereClauses.Add("rating>=@UserRating"); - if (statement != null) - { - statement.TryBind("@UserRating", UserItemData.MinLikeValue); - } + statement?.TryBind("@UserRating", UserItemData.MinLikeValue); } else { whereClauses.Add("(rating is null or rating<@UserRating)"); - if (statement != null) - { - statement.TryBind("@UserRating", UserItemData.MinLikeValue); - } + statement?.TryBind("@UserRating", UserItemData.MinLikeValue); } } @@ -3945,10 +3888,8 @@ namespace Emby.Server.Implementations.Data { whereClauses.Add("(IsFavorite is null or IsFavorite=@IsFavoriteOrLiked)"); } - if (statement != null) - { - statement.TryBind("@IsFavoriteOrLiked", query.IsFavoriteOrLiked.Value); - } + + statement?.TryBind("@IsFavoriteOrLiked", query.IsFavoriteOrLiked.Value); } if (query.IsFavorite.HasValue) @@ -3961,10 +3902,8 @@ namespace Emby.Server.Implementations.Data { whereClauses.Add("(IsFavorite is null or IsFavorite=@IsFavorite)"); } - if (statement != null) - { - statement.TryBind("@IsFavorite", query.IsFavorite.Value); - } + + statement?.TryBind("@IsFavorite", query.IsFavorite.Value); } if (EnableJoinUserData(query)) @@ -3972,7 +3911,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) { @@ -3993,10 +3932,8 @@ namespace Emby.Server.Implementations.Data { whereClauses.Add("(played is null or played=@IsPlayed)"); } - if (statement != null) - { - statement.TryBind("@IsPlayed", query.IsPlayed.Value); - } + + statement?.TryBind("@IsPlayed", query.IsPlayed.Value); } } } @@ -4024,10 +3961,12 @@ namespace Emby.Server.Implementations.Data clauses.Add("(guid in (select itemid from itemvalues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); if (statement != null) { - statement.TryBind(paramName, artistId.ToGuidBlob()); + statement.TryBind(paramName, artistId.ToByteArray()); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4043,10 +3982,12 @@ namespace Emby.Server.Implementations.Data clauses.Add("(guid in (select itemid from itemvalues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=1))"); if (statement != null) { - statement.TryBind(paramName, artistId.ToGuidBlob()); + statement.TryBind(paramName, artistId.ToByteArray()); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4062,10 +4003,12 @@ namespace Emby.Server.Implementations.Data clauses.Add("((select CleanName from TypedBaseItems where guid=" + paramName + ") in (select CleanValue from itemvalues where ItemId=Guid and Type=0) AND (select CleanName from TypedBaseItems where guid=" + paramName + ") not in (select CleanValue from itemvalues where ItemId=Guid and Type=1))"); if (statement != null) { - statement.TryBind(paramName, artistId.ToGuidBlob()); + statement.TryBind(paramName, artistId.ToByteArray()); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4081,10 +4024,12 @@ namespace Emby.Server.Implementations.Data clauses.Add("Album in (select Name from typedbaseitems where guid=" + paramName + ")"); if (statement != null) { - statement.TryBind(paramName, albumId.ToGuidBlob()); + statement.TryBind(paramName, albumId.ToByteArray()); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4100,15 +4045,17 @@ namespace Emby.Server.Implementations.Data clauses.Add("(guid not in (select itemid from itemvalues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); if (statement != null) { - statement.TryBind(paramName, artistId.ToGuidBlob()); + statement.TryBind(paramName, artistId.ToByteArray()); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } - if (query.GenreIds.Length > 0) + if (query.GenreIds.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -4119,15 +4066,17 @@ namespace Emby.Server.Implementations.Data clauses.Add("(guid in (select itemid from itemvalues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=2))"); if (statement != null) { - statement.TryBind(paramName, genreId.ToGuidBlob()); + statement.TryBind(paramName, genreId.ToByteArray()); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } - if (query.Genres.Length > 0) + if (query.Genres.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -4138,8 +4087,10 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@Genre" + index, GetCleanValue(item)); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4155,8 +4106,10 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@Tag" + index, GetCleanValue(item)); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4172,8 +4125,10 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@ExcludeTag" + index, GetCleanValue(item)); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4190,10 +4145,12 @@ namespace Emby.Server.Implementations.Data if (statement != null) { - statement.TryBind(paramName, studioId.ToGuidBlob()); + statement.TryBind(paramName, studioId.ToByteArray()); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4209,8 +4166,10 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@OfficialRating" + index, item); } + index++; } + var clause = "(" + string.Join(" OR ", clauses) + ")"; whereClauses.Add(clause); } @@ -4366,12 +4325,12 @@ namespace Emby.Server.Implementations.Data whereClauses.Add("ProductionYear=@Years"); if (statement != null) { - statement.TryBind("@Years", query.Years[0].ToString()); + statement.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture)); } } else if (query.Years.Length > 1) { - var val = string.Join(",", query.Years); + var val = string.Join(',', query.Years); whereClauses.Add("ProductionYear in (" + val + ")"); } @@ -4385,6 +4344,7 @@ namespace Emby.Server.Implementations.Data statement.TryBind("@IsVirtualItem", isVirtualItem.Value); } } + if (query.IsSpecialSeason.HasValue) { if (query.IsSpecialSeason.Value) @@ -4396,6 +4356,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add("IndexNumber <> 0"); } } + if (query.IsUnaired.HasValue) { if (query.IsUnaired.Value) @@ -4407,6 +4368,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add("PremiereDate < DATETIME('now')"); } } + var queryMediaTypes = query.MediaTypes.Where(IsValidMediaType).ToArray(); if (queryMediaTypes.Length == 1) { @@ -4418,10 +4380,11 @@ 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 + ")"); } + if (query.ItemIds.Length > 0) { var includeIds = new List<string>(); @@ -4434,11 +4397,13 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@IncludeId" + index, id); } + index++; } whereClauses.Add("(" + string.Join(" OR ", includeIds) + ")"); } + if (query.ExcludeItemIds.Length > 0) { var excludeIds = new List<string>(); @@ -4451,20 +4416,21 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@ExcludeId" + index, id); } + index++; } whereClauses.Add(string.Join(" AND ", excludeIds)); } - if (query.ExcludeProviderIds.Count > 0) + if (query.ExcludeProviderIds != null && query.ExcludeProviderIds.Count > 0) { var excludeIds = new List<string>(); var index = 0; foreach (var pair in query.ExcludeProviderIds) { - if (string.Equals(pair.Key, MetadataProviders.TmdbCollection.ToString(), StringComparison.OrdinalIgnoreCase)) + if (string.Equals(pair.Key, MetadataProvider.TmdbCollection.ToString(), StringComparison.OrdinalIgnoreCase)) { continue; } @@ -4475,6 +4441,7 @@ namespace Emby.Server.Implementations.Data { statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); } + index++; break; @@ -4486,21 +4453,21 @@ namespace Emby.Server.Implementations.Data } } - if (query.HasAnyProviderId.Count > 0) + if (query.HasAnyProviderId != null && query.HasAnyProviderId.Count > 0) { var hasProviderIds = new List<string>(); var index = 0; foreach (var pair in query.HasAnyProviderId) { - if (string.Equals(pair.Key, MetadataProviders.TmdbCollection.ToString(), StringComparison.OrdinalIgnoreCase)) + if (string.Equals(pair.Key, MetadataProvider.TmdbCollection.ToString(), StringComparison.OrdinalIgnoreCase)) { continue; } // TODO this seems to be an idea for a better schema where ProviderIds are their own table // buut this is not implemented - //hasProviderIds.Add("(COALESCE((select value from ProviderIds where ItemId=Guid and Name = '" + pair.Key + "'), '') <> " + paramName + ")"); + // hasProviderIds.Add("(COALESCE((select value from ProviderIds where ItemId=Guid and Name = '" + pair.Key + "'), '') <> " + paramName + ")"); // TODO this is a really BAD way to do it since the pair: // Tmdb, 1234 matches Tmdb=1234 but also Tmdb=1234567 @@ -4510,13 +4477,14 @@ 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) { statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); } + index++; break; @@ -4530,68 +4498,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)"); + } + + statement?.TryBind("@TopParentId", queryTopParentIds[0].ToString("N", CultureInfo.InvariantCulture)); } - 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) + else if (queryTopParentIds.Length > 1) { - statement.TryBind("@TopParentId", queryTopParentIds[0].ToString("N")); - } - } - else if (queryTopParentIds.Length > 1) - { - var val = string.Join(",", queryTopParentIds.Select(i => "'" + i.ToString("N") + "'")); + var val = string.Join(',', queryTopParentIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); - if (enableItemsByName && includedItemByNameTypes.Count == 1) - { - whereClauses.Add("(Type=@IncludedItemByNameType or TopParentId in (" + val + "))"); - if (statement != null) + 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 + ")"); } } @@ -4604,15 +4567,17 @@ namespace Emby.Server.Implementations.Data statement.TryBind("@AncestorId", query.AncestorIds[0]); } } + if (query.AncestorIds.Length > 1) { - var inClause = string.Join(",", query.AncestorIds.Select(i => "'" + i.ToString("N") + "'")); - whereClauses.Add(string.Format("Guid in (select itemId from AncestorIds where AncestorIdText in ({0}))", inClause)); + 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)); } + if (!string.IsNullOrWhiteSpace(query.AncestorWithPresentationUniqueKey)) { var inClause = "select guid from TypedBaseItems where PresentationUniqueKey=@AncestorWithPresentationUniqueKey"; - whereClauses.Add(string.Format("Guid in (select itemId from AncestorIds where AncestorId in ({0}))", inClause)); + whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorId in ({0}))", inClause)); if (statement != null) { statement.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey); @@ -4637,18 +4602,33 @@ namespace Emby.Server.Implementations.Data statement.TryBind("@UnratedType", query.BlockUnratedItems[0].ToString()); } } + if (query.BlockUnratedItems.Length > 1) { - var inClause = string.Join(",", query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'")); - whereClauses.Add(string.Format("(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", inClause)); + var inClause = string.Join(',', query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'")); + whereClauses.Add( + string.Format( + CultureInfo.InvariantCulture, + "(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", + inClause)); } if (query.ExcludeInheritedTags.Length > 0) { - var tagValues = query.ExcludeInheritedTags.Select(i => "'" + GetCleanValue(i) + "'"); - var tagValuesList = string.Join(",", tagValues); - - whereClauses.Add("((select CleanValue from itemvalues where ItemId=Guid and Type=6 and cleanvalue in (" + tagValuesList + ")) is null)"); + var paramName = "@ExcludeInheritedTags"; + if (statement == null) + { + int index = 0; + string excludedTags = string.Join(',', query.ExcludeInheritedTags.Select(t => paramName + index++)); + whereClauses.Add("((select CleanValue from itemvalues where ItemId=Guid and Type=6 and cleanvalue in (" + excludedTags + ")) is null)"); + } + else + { + for (int index = 0; index < query.ExcludeInheritedTags.Length; index++) + { + statement.TryBind(paramName + index, GetCleanValue(query.ExcludeInheritedTags[index])); + } + } } if (query.SeriesStatuses.Length > 0) @@ -4669,7 +4649,7 @@ namespace Emby.Server.Implementations.Data foreach (var folderId in query.BoxSetLibraryFolders) { - folderIdQueries.Add("data like '%" + folderId.ToString("N") + "%'"); + folderIdQueries.Add("data like '%" + folderId.ToString("N", CultureInfo.InvariantCulture) + "%'"); } whereClauses.Add("(" + string.Join(" OR ", folderIdQueries) + ")"); @@ -4762,29 +4742,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; @@ -4837,17 +4836,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; } @@ -4855,105 +4849,64 @@ namespace Emby.Server.Implementations.Data return false; } - private static readonly Type[] KnownTypes = + public void UpdateInheritedValues() { - typeof(LiveTvProgram), - typeof(LiveTvChannel), - typeof(Series), - typeof(Audio), - typeof(MusicAlbum), - typeof(MusicArtist), - typeof(MusicGenre), - typeof(MusicVideo), - typeof(Movie), - typeof(Playlist), - typeof(AudioBook), - typeof(Trailer), - typeof(BoxSet), - typeof(Episode), - typeof(Season), - typeof(Series), - typeof(Book), - typeof(CollectionFolder), - typeof(Folder), - typeof(Genre), - typeof(Person), - typeof(Photo), - typeof(PhotoAlbum), - typeof(Studio), - typeof(UserRootFolder), - typeof(UserView), - typeof(Video), - typeof(Year), - typeof(Channel), - typeof(AggregateFolder) - }; - - public void UpdateInheritedValues(CancellationToken cancellationToken) - { - UpdateInheritedTags(cancellationToken); - } - - private void UpdateInheritedTags(CancellationToken cancellationToken) - { - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) + string sql = string.Join( + ';', + new string[] { - connection.RunInTransaction(db => - { - connection.ExecuteAll(string.Join(";", new string[] - { - "delete from itemvalues where type = 6", + "delete from itemvalues where type = 6", - "insert into itemvalues (ItemId, Type, Value, CleanValue) select ItemId, 6, Value, CleanValue from ItemValues where Type=4", + "insert into itemvalues (ItemId, Type, Value, CleanValue) select ItemId, 6, Value, CleanValue from ItemValues where Type=4", - @"insert into itemvalues (ItemId, Type, Value, CleanValue) select AncestorIds.itemid, 6, ItemValues.Value, ItemValues.CleanValue + @"insert into itemvalues (ItemId, Type, Value, CleanValue) select AncestorIds.itemid, 6, ItemValues.Value, ItemValues.CleanValue FROM AncestorIds LEFT JOIN ItemValues ON (AncestorIds.AncestorId = ItemValues.ItemId) where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type = 4 " + }); - })); - - }, TransactionMode); - } + using (var connection = GetConnection()) + { + 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) + 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 - readonly Dictionary<string, string[]> _types = GetTypeMapDictionary(); - - private IEnumerable<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, CancellationToken cancellationToken) + public void DeleteItem(Guid id) { if (id == Guid.Empty) { @@ -4962,37 +4915,35 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - using (WriteLock.Write()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => - { - var idBlob = id.ToGuidBlob(); + var idBlob = id.ToByteArray(); - // Delete people - ExecuteWithSingleParam(db, "delete from People where ItemId=@Id", idBlob); + // Delete people + ExecuteWithSingleParam(db, "delete from People where ItemId=@Id", idBlob); - // Delete chapters - ExecuteWithSingleParam(db, "delete from " + ChaptersTableName + " where ItemId=@Id", idBlob); + // Delete chapters + ExecuteWithSingleParam(db, "delete from " + ChaptersTableName + " where ItemId=@Id", idBlob); - // Delete media streams - ExecuteWithSingleParam(db, "delete from mediastreams where ItemId=@Id", idBlob); + // Delete media streams + ExecuteWithSingleParam(db, "delete from mediastreams where ItemId=@Id", idBlob); - // Delete ancestors - ExecuteWithSingleParam(db, "delete from AncestorIds where ItemId=@Id", idBlob); + // Delete ancestors + ExecuteWithSingleParam(db, "delete from AncestorIds where ItemId=@Id", idBlob); - // Delete item values - ExecuteWithSingleParam(db, "delete from ItemValues where ItemId=@Id", idBlob); + // Delete item values + ExecuteWithSingleParam(db, "delete from ItemValues where ItemId=@Id", idBlob); - // Delete the item - ExecuteWithSingleParam(db, "delete from TypedBaseItems where guid=@Id", idBlob); - }, TransactionMode); - } + // Delete the item + ExecuteWithSingleParam(db, "delete from TypedBaseItems where guid=@Id", idBlob); + }, TransactionMode); } } - private void ExecuteWithSingleParam(IDatabaseConnection db, string query, byte[] value) + private void ExecuteWithSingleParam(IDatabaseConnection db, string query, ReadOnlySpan<byte> value) { using (var statement = PrepareStatement(db, query)) { @@ -5011,34 +4962,37 @@ 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"); - using (WriteLock.Read()) + if (query.Limit > 0) { - using (var connection = CreateConnection(true)) + commandText.Append(" LIMIT ").Append(query.Limit); + } + + using (var connection = GetConnection(true)) + { + var list = new List<string>(); + using (var statement = PrepareStatement(connection, commandText.ToString())) { - var list = new List<string>(); - using (var statement = PrepareStatementSafe(connection, commandText)) - { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); + // Run this again to bind the params + GetPeopleWhereClauses(query, statement); - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row.GetString(0)); - } + foreach (var row in statement.ExecuteQuery()) + { + list.Add(row.GetString(0)); } - return list; } + + return list; } } @@ -5051,7 +5005,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,25 +5016,27 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type commandText += " order by ListOrder"; - using (WriteLock.Read()) + if (query.Limit > 0) + { + commandText += " LIMIT " + query.Limit; + } + + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + var list = new List<PersonInfo>(); + + using (var statement = PrepareStatement(connection, commandText)) { - var list = new List<PersonInfo>(); + // Run this again to bind the params + GetPeopleWhereClauses(query, statement); - using (var statement = PrepareStatementSafe(connection, commandText)) + foreach (var row in statement.ExecuteQuery()) { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetPerson(row)); - } + list.Add(GetPerson(row)); } - - return list; } + + return list; } } @@ -5088,69 +5044,71 @@ 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.ToGuidBlob()); - } + 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.ToGuidBlob()); - } + 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(); 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 + ")"); } + var queryExcludePersonTypes = query.ExcludePersonTypes.Where(IsValidPersonType).ToList(); 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 + ")"); } + 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; @@ -5170,7 +5128,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var itemIdBlob = itemId.ToGuidBlob(); + Span<byte> itemIdBlob = stackalloc byte[16]; + itemId.TryWriteBytes(itemIdBlob); // First delete deleteAncestorsStatement.Reset(); @@ -5186,15 +5145,16 @@ 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("(@ItemId, @AncestorId{0}, @AncestorIdText{0})", i.ToString(CultureInfo.InvariantCulture)); + insertText.AppendFormat( + CultureInfo.InvariantCulture, + "(@ItemId, @AncestorId{0}, @AncestorIdText{0}),", + i.ToString(CultureInfo.InvariantCulture)); } - using (var statement = PrepareStatementSafe(db, insertText.ToString())) + // Remove last , + insertText.Length--; + + using (var statement = PrepareStatement(db, insertText.ToString())) { statement.TryBind("@ItemId", itemIdBlob); @@ -5203,9 +5163,10 @@ 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.ToGuidBlob()); - statement.TryBind("@AncestorIdText" + index, ancestorId.ToString("N")); + statement.TryBind("@AncestorId" + index, itemIdBlob); + statement.TryBind("@AncestorIdText" + index, ancestorId.ToString("N", CultureInfo.InvariantCulture)); } statement.Reset(); @@ -5245,74 +5206,93 @@ 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(); - using (WriteLock.Read()) + var list = new List<string>(); + using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - using (var connection = CreateConnection(true)) + foreach (var row in statement.ExecuteQuery()) { - var list = new List<string>(); - - using (var statement = PrepareStatementSafe(connection, commandText)) + if (row.TryGetString(0, out var result)) { - foreach (var row in statement.ExecuteQuery()) - { - if (!row.IsDBNull(0)) - { - list.Add(row.GetString(0)); - } - } + list.Add(result); } - - LogQueryTime("GetItemValueNames", commandText, now); - - return list; } } + + LogQueryTime("GetItemValueNames", commandText, now); + return list; } private QueryResult<(BaseItem, ItemCounts)> GetItemValues(InternalItemsQuery query, int[] itemValueTypes, string returnType) @@ -5332,18 +5312,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) { @@ -5359,20 +5340,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 @@ -5385,7 +5368,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, @@ -5394,23 +5376,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, @@ -5420,6 +5403,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, @@ -5430,21 +5414,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) @@ -5453,47 +5447,51 @@ 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(); } - using (WriteLock.Read()) + var list = new List<(BaseItem, ItemCounts)>(); + var result = new QueryResult<(BaseItem, ItemCounts)>(); + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) - { - return connection.RunInTransaction(db => + connection.RunInTransaction( + db => { - var list = new List<(BaseItem, ItemCounts)>(); - var result = new QueryResult<(BaseItem, ItemCounts)>(); - - var statements = PrepareAllSafe(db, statementTexts); - if (!isReturningZeroItems) { - using (var statement = statements[0]) + using (var statement = PrepareStatement(db, commandText)) { statement.TryBind("@SelectType", returnType); if (EnableJoinUserData(query)) @@ -5505,6 +5503,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type { GetWhereClauses(typeSubQuery, null); } + BindSimilarParams(query, statement); BindSearchParams(query, statement); GetWhereClauses(innerQuery, statement); @@ -5528,20 +5527,12 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type list.Add((item, GetItemCounts(row, countStartColumn, typesToCount))); } } - - LogQueryTime("GetItemValues", commandText, now); } } 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)) @@ -5553,31 +5544,32 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type { GetWhereClauses(typeSubQuery, null); } + BindSimilarParams(query, statement); BindSearchParams(query, statement); GetWhereClauses(innerQuery, statement); GetWhereClauses(outerQuery, statement); result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); - - LogQueryTime("GetItemValues", commandText, now); } } + }, + ReadTransactionMode); + } - if (result.TotalRecordCount == 0) - { - result.TotalRecordCount = list.Count; - } - result.Items = list.ToArray(); - - return result; + LogQueryTime("GetItemValues", commandText, now); - }, ReadTransactionMode); - } + if (result.TotalRecordCount == 0) + { + result.TotalRecordCount = list.Count; } + + result.Items = list; + + 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(); @@ -5586,81 +5578,74 @@ 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(i => i); - - 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; } - private List<Tuple<int, string>> GetItemValuesToSave(BaseItem item, List<string> inheritedTags) + private List<(int, string)> GetItemValuesToSave(BaseItem item, List<string> inheritedTags) { - var list = new List<Tuple<int, string>>(); + var list = new List<(int, string)>(); if (item is IHasArtist hasArtist) { - list.AddRange(hasArtist.Artists.Select(i => new Tuple<int, string>(0, i))); + list.AddRange(hasArtist.Artists.Select(i => (0, i))); } if (item is IHasAlbumArtist hasAlbumArtist) { - list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => new Tuple<int, string>(1, i))); + list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (1, i))); } - list.AddRange(item.Genres.Select(i => new Tuple<int, string>(2, i))); - list.AddRange(item.Studios.Select(i => new Tuple<int, string>(3, i))); - list.AddRange(item.Tags.Select(i => new Tuple<int, string>(4, i))); + list.AddRange(item.Genres.Select(i => (2, i))); + list.AddRange(item.Studios.Select(i => (3, i))); + list.AddRange(item.Tags.Select(i => (4, i))); // keywords was 5 - list.AddRange(inheritedTags.Select(i => new Tuple<int, string>(6, i))); + list.AddRange(inheritedTags.Select(i => (6, i))); return list; } - private void UpdateItemValues(Guid itemId, List<Tuple<int, string>> values, IDatabaseConnection db) + private void UpdateItemValues(Guid itemId, List<(int, string)> values, IDatabaseConnection db) { if (itemId.Equals(Guid.Empty)) { @@ -5674,7 +5659,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var guidBlob = itemId.ToGuidBlob(); + var guidBlob = itemId.ToByteArray(); // First delete db.Execute("delete from ItemValues where ItemId=@Id", guidBlob); @@ -5682,30 +5667,29 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type InsertItemValues(guidBlob, values, db); } - private void InsertItemValues(byte[] idBlob, List<Tuple<int, string>> values, IDatabaseConnection db) + private void InsertItemValues(byte[] idBlob, List<(int, string)> values, IDatabaseConnection db) { + const int Limit = 100; var startIndex = 0; - var limit = 100; + const string StartInsertText = "insert into ItemValues (ItemId, Type, Value, CleanValue) values "; + var insertText = new StringBuilder(StartInsertText); while (startIndex < values.Count) { - var insertText = new StringBuilder("insert into ItemValues (ItemId, Type, Value, CleanValue) values "); - - var endIndex = Math.Min(values.Count, startIndex + limit); - var isSubsequentRow = false; + var endIndex = Math.Min(values.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) { - if (isSubsequentRow) - { - insertText.Append(","); - } - - insertText.AppendFormat("(@ItemId, @Type{0}, @Value{0}, @CleanValue{0})", i.ToString(CultureInfo.InvariantCulture)); - isSubsequentRow = true; + insertText.AppendFormat( + CultureInfo.InvariantCulture, + "(@ItemId, @Type{0}, @Value{0}, @CleanValue{0}),", + i); } - using (var statement = PrepareStatementSafe(db, insertText.ToString())) + // Remove last comma + insertText.Length--; + + using (var statement = PrepareStatement(db, insertText.ToString())) { statement.TryBind("@ItemId", idBlob); @@ -5732,7 +5716,8 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type statement.MoveNext(); } - startIndex += limit; + startIndex += Limit; + insertText.Length = StartInsertText.Length; } } @@ -5750,50 +5735,44 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - using (WriteLock.Write()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => - { - var itemIdBlob = itemId.ToGuidBlob(); - - // First delete chapters - db.Execute("delete from People where ItemId=@ItemId", itemIdBlob); + var itemIdBlob = itemId.ToByteArray(); - InsertPeople(itemIdBlob, people, db); + // First delete chapters + db.Execute("delete from People where ItemId=@ItemId", itemIdBlob); - }, TransactionMode); - - } + InsertPeople(itemIdBlob, people, db); + }, TransactionMode); } } private void InsertPeople(byte[] idBlob, List<PersonInfo> people, IDatabaseConnection db) { + const int Limit = 100; var startIndex = 0; - var limit = 100; var listIndex = 0; + const string StartInsertText = "insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values "; + var insertText = new StringBuilder(StartInsertText); while (startIndex < people.Count) { - var insertText = new StringBuilder("insert into People (ItemId, Name, Role, PersonType, SortOrder, ListOrder) values "); - - var endIndex = Math.Min(people.Count, startIndex + limit); - var isSubsequentRow = false; - + var endIndex = Math.Min(people.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) { - if (isSubsequentRow) - { - insertText.Append(","); - } - - insertText.AppendFormat("(@ItemId, @Name{0}, @Role{0}, @PersonType{0}, @SortOrder{0}, @ListOrder{0})", i.ToString(CultureInfo.InvariantCulture)); - isSubsequentRow = true; + insertText.AppendFormat( + CultureInfo.InvariantCulture, + "(@ItemId, @Name{0}, @Role{0}, @PersonType{0}, @SortOrder{0}, @ListOrder{0}),", + i.ToString(CultureInfo.InvariantCulture)); } - using (var statement = PrepareStatementSafe(db, insertText.ToString())) + // Remove last comma + insertText.Length--; + + using (var statement = PrepareStatement(db, insertText.ToString())) { statement.TryBind("@ItemId", idBlob); @@ -5816,30 +5795,32 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type statement.MoveNext(); } - startIndex += limit; + startIndex += Limit; + insertText.Length = StartInsertText.Length; } } - private PersonInfo GetPerson(IReadOnlyList<IResultSetValue> reader) + private PersonInfo GetPerson(IReadOnlyList<ResultSetValue> reader) { - var item = new PersonInfo(); - - item.ItemId = reader.GetGuid(0); - item.Name = reader.GetString(1); + var item = new PersonInfo + { + ItemId = reader.GetGuid(0), + 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; @@ -5854,10 +5835,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type throw new ArgumentNullException(nameof(query)); } - var cmdText = "select " - + string.Join(",", _mediaStreamSaveColumns) - + " from mediastreams where" - + " ItemId=@ItemId"; + var cmdText = _mediaStreamSaveColumnsSelectQuery; if (query.Type.HasValue) { @@ -5871,34 +5849,31 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type cmdText += " order by StreamIndex ASC"; - using (WriteLock.Read()) + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + var list = new List<MediaStream>(); + + using (var statement = PrepareStatement(connection, cmdText)) { - var list = new List<MediaStream>(); + statement.TryBind("@ItemId", query.ItemId.ToByteArray()); - using (var statement = PrepareStatementSafe(connection, cmdText)) + if (query.Type.HasValue) { - statement.TryBind("@ItemId", query.ItemId.ToGuidBlob()); - - if (query.Type.HasValue) - { - statement.TryBind("@StreamType", query.Type.Value.ToString()); - } - - if (query.Index.HasValue) - { - statement.TryBind("@StreamIndex", query.Index.Value); - } + statement.TryBind("@StreamType", query.Type.Value.ToString()); + } - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetMediaStream(row)); - } + if (query.Index.HasValue) + { + statement.TryBind("@StreamIndex", query.Index.Value); } - return list; + foreach (var row in statement.ExecuteQuery()) + { + list.Add(GetMediaStream(row)); + } } + + return list; } } @@ -5918,40 +5893,36 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type cancellationToken.ThrowIfCancellationRequested(); - using (WriteLock.Write()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => - { - var itemIdBlob = id.ToGuidBlob(); + var itemIdBlob = id.ToByteArray(); - // First delete chapters - db.Execute("delete from mediastreams where ItemId=@ItemId", itemIdBlob); + // First delete chapters + db.Execute("delete from mediastreams where ItemId=@ItemId", itemIdBlob); - InsertMediaStreams(itemIdBlob, streams, db); - - }, TransactionMode); - } + InsertMediaStreams(itemIdBlob, streams, db); + }, TransactionMode); } } private void InsertMediaStreams(byte[] idBlob, List<MediaStream> streams, IDatabaseConnection db) { + const int Limit = 10; var startIndex = 0; - var limit = 10; + var insertText = new StringBuilder(_mediaStreamSaveColumnsInsertQuery); while (startIndex < streams.Count) { - var insertText = new StringBuilder(string.Format("insert into mediastreams ({0}) values ", string.Join(",", _mediaStreamSaveColumns))); - - var endIndex = Math.Min(streams.Count, startIndex + limit); + var endIndex = Math.Min(streams.Count, startIndex + Limit); for (var i = startIndex; i < endIndex; i++) { if (i != startIndex) { - insertText.Append(","); + insertText.Append(','); } var index = i.ToString(CultureInfo.InvariantCulture); @@ -5959,14 +5930,15 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type foreach (var column in _mediaStreamSaveColumns.Skip(1)) { - insertText.Append("@" + column + index + ","); + insertText.Append('@').Append(column).Append(index).Append(','); } + insertText.Length -= 1; // Remove the last comma - insertText.Append(")"); + insertText.Append(')'); } - using (var statement = PrepareStatementSafe(db, insertText.ToString())) + using (var statement = PrepareStatement(db, insertText.ToString())) { statement.TryBind("@ItemId", idBlob); @@ -6025,169 +5997,347 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type statement.MoveNext(); } - startIndex += limit; + startIndex += Limit; + insertText.Length = _mediaStreamSaveColumnsInsertQuery.Length; } } - /// <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 { Index = reader[1].ToInt() }; - item.Type = (MediaStreamType)Enum.Parse(typeof(MediaStreamType), reader[2].ToString(), true); + 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 = width; + } + + if (reader.TryGetInt32(17, out var height)) + { + item.Height = height; + } + + if (reader.TryGetSingle(18, out var averageFrameRate)) + { + item.AverageFrameRate = averageFrameRate; + } + + if (reader.TryGetSingle(19, out var realFrameRate)) { - item.Width = reader.GetInt32(16); + item.RealFrameRate = realFrameRate; } - if (reader[17].SQLiteType != SQLiteType.Null) + if (reader.TryGetSingle(20, out var level)) { - item.Height = reader.GetInt32(17); + item.Level = level; } - if (reader[18].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(21, out var pixelFormat)) { - item.AverageFrameRate = reader.GetFloat(18); + item.PixelFormat = pixelFormat; } - if (reader[19].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(22, out var bitDepth)) { - item.RealFrameRate = reader.GetFloat(19); + item.BitDepth = bitDepth; } - if (reader[20].SQLiteType != SQLiteType.Null) + if (reader.TryGetBoolean(23, out var isAnamorphic)) { - item.Level = reader.GetFloat(20); + item.IsAnamorphic = isAnamorphic; } - if (reader[21].SQLiteType != SQLiteType.Null) + if (reader.TryGetInt32(24, out var refFrames)) { - item.PixelFormat = reader[21].ToString(); + item.RefFrames = refFrames; } - if (reader[22].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(25, out var codecTag)) { - item.BitDepth = reader.GetInt32(22); + item.CodecTag = codecTag; } - if (reader[23].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(26, out var comment)) { - item.IsAnamorphic = reader.GetBoolean(23); + item.Comment = comment; } - if (reader[24].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(27, out var nalLengthSize)) { - item.RefFrames = reader.GetInt32(24); + item.NalLengthSize = nalLengthSize; } - if (reader[25].SQLiteType != SQLiteType.Null) + if (reader.TryGetBoolean(28, out var isAVC)) { - item.CodecTag = reader.GetString(25); + item.IsAVC = isAVC; } - if (reader[26].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(29, out var title)) { - item.Comment = reader.GetString(26); + item.Title = title; } - if (reader[27].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(30, out var timeBase)) { - item.NalLengthSize = reader.GetString(27); + item.TimeBase = timeBase; } - if (reader[28].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(31, out var codecTimeBase)) { - item.IsAVC = reader[28].ToBool(); + item.CodecTimeBase = codecTimeBase; } - if (reader[29].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(32, out var colorPrimaries)) { - item.Title = reader[29].ToString(); + item.ColorPrimaries = colorPrimaries; } - if (reader[30].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(33, out var colorSpace)) + { + item.ColorSpace = colorSpace; + } + + if (reader.TryGetString(34, out var colorTransfer)) + { + item.ColorTransfer = colorTransfer; + } + + if (item.Type == MediaStreamType.Subtitle) + { + item.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); + item.LocalizedDefault = _localization.GetLocalizedString("Default"); + item.LocalizedForced = _localization.GetLocalizedString("Forced"); + } + + return item; + } + + public List<MediaAttachment> GetMediaAttachments(MediaAttachmentQuery query) + { + CheckDisposed(); + + if (query == null) + { + throw new ArgumentNullException(nameof(query)); + } + + var cmdText = _mediaAttachmentSaveColumnsSelectQuery; + + if (query.Index.HasValue) + { + cmdText += " AND AttachmentIndex=@AttachmentIndex"; + } + + cmdText += " order by AttachmentIndex ASC"; + + var list = new List<MediaAttachment>(); + using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, cmdText)) + { + statement.TryBind("@ItemId", query.ItemId.ToByteArray()); + + if (query.Index.HasValue) + { + statement.TryBind("@AttachmentIndex", query.Index.Value); + } + + foreach (var row in statement.ExecuteQuery()) + { + list.Add(GetMediaAttachment(row)); + } + } + + return list; + } + + public void SaveMediaAttachments( + Guid id, + IReadOnlyList<MediaAttachment> attachments, + CancellationToken cancellationToken) + { + CheckDisposed(); + if (id == Guid.Empty) + { + throw new ArgumentException("Guid can't be empty.", nameof(id)); + } + + if (attachments == null) + { + throw new ArgumentNullException(nameof(attachments)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + using (var connection = GetConnection()) + { + connection.RunInTransaction( + db => + { + var itemIdBlob = id.ToByteArray(); + + db.Execute("delete from mediaattachments where ItemId=@ItemId", itemIdBlob); + + InsertMediaAttachments(itemIdBlob, attachments, db, cancellationToken); + }, TransactionMode); + } + } + + private void InsertMediaAttachments( + byte[] idBlob, + IReadOnlyList<MediaAttachment> attachments, + IDatabaseConnection db, + CancellationToken cancellationToken) + { + const int InsertAtOnce = 10; + + var insertText = new StringBuilder(_mediaAttachmentInsertPrefix); + for (var startIndex = 0; startIndex < attachments.Count; startIndex += InsertAtOnce) + { + var endIndex = Math.Min(attachments.Count, startIndex + InsertAtOnce); + + for (var i = startIndex; i < endIndex; i++) + { + var index = i.ToString(CultureInfo.InvariantCulture); + insertText.Append("(@ItemId, "); + + foreach (var column in _mediaAttachmentSaveColumns.Skip(1)) + { + insertText.Append('@') + .Append(column) + .Append(index) + .Append(','); + } + + insertText.Length -= 1; + + insertText.Append("),"); + } + + insertText.Length--; + + cancellationToken.ThrowIfCancellationRequested(); + + using (var statement = PrepareStatement(db, insertText.ToString())) + { + statement.TryBind("@ItemId", idBlob); + + for (var i = startIndex; i < endIndex; i++) + { + var index = i.ToString(CultureInfo.InvariantCulture); + + var attachment = attachments[i]; + + statement.TryBind("@AttachmentIndex" + index, attachment.Index); + statement.TryBind("@Codec" + index, attachment.Codec); + statement.TryBind("@CodecTag" + index, attachment.CodecTag); + statement.TryBind("@Comment" + index, attachment.Comment); + statement.TryBind("@Filename" + index, attachment.FileName); + statement.TryBind("@MIMEType" + index, attachment.MimeType); + } + + statement.Reset(); + statement.MoveNext(); + } + + insertText.Length = _mediaAttachmentInsertPrefix.Length; + } + } + + /// <summary> + /// Gets the attachment. + /// </summary> + /// <param name="reader">The reader.</param> + /// <returns>MediaAttachment.</returns> + private MediaAttachment GetMediaAttachment(IReadOnlyList<ResultSetValue> reader) + { + var item = new MediaAttachment + { + Index = reader[1].ToInt() + }; + + if (reader.TryGetString(2, out var codec)) { - item.TimeBase = reader[30].ToString(); + item.Codec = codec; } - if (reader[31].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(3, out var codecTag)) { - item.CodecTimeBase = reader[31].ToString(); + item.CodecTag = codecTag; } - if (reader[32].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(4, out var comment)) { - item.ColorPrimaries = reader[32].ToString(); + item.Comment = comment; } - if (reader[33].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(5, out var fileName)) { - item.ColorSpace = reader[33].ToString(); + item.FileName = fileName; } - if (reader[34].SQLiteType != SQLiteType.Null) + if (reader.TryGetString(6, out var mimeType)) { - item.ColorTransfer = reader[34].ToString(); + item.MimeType = mimeType; } return item; diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index 7a9b72244..107096b5f 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -1,13 +1,16 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Threading; +using Jellyfin.Data.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -16,41 +19,41 @@ namespace Emby.Server.Implementations.Data public class SqliteUserDataRepository : BaseSqliteRepository, IUserDataRepository { public SqliteUserDataRepository( - ILoggerFactory loggerFactory, + ILogger<SqliteUserDataRepository> logger, IApplicationPaths appPaths) - : base(loggerFactory.CreateLogger(nameof(SqliteUserDataRepository))) + : base(logger) { DbFilePath = Path.Combine(appPaths.DataPath, "library.db"); } - /// <summary> - /// Gets the name of the repository - /// </summary> - /// <value>The name.</value> + /// <inheritdoc /> public string Name => "SQLite"; /// <summary> - /// Opens the connection to the database + /// Opens the connection to the database. /// </summary> - /// <returns>Task.</returns> - public void Initialize(ReaderWriterLockSlim writeLock, ManagedConnection managedConnection, IUserManager userManager) + /// <param name="userManager">The user manager.</param> + /// <param name="dbLock">The lock to use for database IO.</param> + /// <param name="dbConnection">The connection to use for database IO.</param> + public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection) { - _connection = managedConnection; - WriteLock.Dispose(); - WriteLock = writeLock; + WriteLock = dbLock; + WriteConnection?.Dispose(); + WriteConnection = dbConnection; - using (var connection = CreateConnection()) + using (var connection = GetConnection()) { var userDatasTableExists = TableExists(connection, "UserDatas"); var userDataTableExists = TableExists(connection, "userdata"); - var users = userDatasTableExists ? null : userManager.Users.ToArray(); + 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)", "drop index if exists idx_userdata", @@ -85,7 +88,7 @@ namespace Emby.Server.Implementations.Data } } - private void ImportUserIds(IDatabaseConnection db, User[] users) + private void ImportUserIds(IDatabaseConnection db, IEnumerable<User> users) { var userIdsWithUserData = GetAllUserIdsWithUserData(db); @@ -98,7 +101,7 @@ namespace Emby.Server.Implementations.Data continue; } - statement.TryBind("@UserId", user.Id.ToGuidBlob()); + statement.TryBind("@UserId", user.Id.ToByteArray()); statement.TryBind("@InternalUserId", user.InternalId); statement.MoveNext(); @@ -119,9 +122,9 @@ namespace Emby.Server.Implementations.Data { list.Add(row[0].ReadGuidFromBlob()); } - catch + catch (Exception ex) { - + Logger.LogError(ex, "Error while getting user"); } } } @@ -129,41 +132,41 @@ namespace Emby.Server.Implementations.Data return list; } - protected override bool EnableTempStoreMemory => true; - - /// <summary> - /// Saves the user data. - /// </summary> - public void SaveUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) + /// <inheritdoc /> + public void SaveUserData(long userId, string key, UserItemData userData, CancellationToken cancellationToken) { if (userData == null) { throw new ArgumentNullException(nameof(userData)); } - if (internalUserId <= 0) + + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } + if (string.IsNullOrEmpty(key)) { throw new ArgumentNullException(nameof(key)); } - PersistUserData(internalUserId, key, userData, cancellationToken); + PersistUserData(userId, key, userData, cancellationToken); } - public void SaveAllUserData(long internalUserId, UserItemData[] userData, CancellationToken cancellationToken) + /// <inheritdoc /> + public void SaveAllUserData(long userId, UserItemData[] userData, CancellationToken cancellationToken) { if (userData == null) { throw new ArgumentNullException(nameof(userData)); } - if (internalUserId <= 0) + + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } - PersistAllUserData(internalUserId, userData, cancellationToken); + PersistAllUserData(userId, userData, cancellationToken); } /// <summary> @@ -173,20 +176,17 @@ namespace Emby.Server.Implementations.Data /// <param name="key">The key.</param> /// <param name="userData">The user data.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> public void PersistUserData(long internalUserId, string key, UserItemData userData, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - using (WriteLock.Write()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => - { - SaveUserData(db, internalUserId, key, userData); - }, TransactionMode); - } + SaveUserData(db, internalUserId, key, userData); + }, TransactionMode); } } @@ -243,70 +243,66 @@ namespace Emby.Server.Implementations.Data } /// <summary> - /// Persist all user data for the specified user + /// Persist all user data for the specified user. /// </summary> private void PersistAllUserData(long internalUserId, UserItemData[] userDataList, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - using (WriteLock.Write()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + connection.RunInTransaction( + db => { - connection.RunInTransaction(db => + foreach (var userItemData in userDataList) { - foreach (var userItemData in userDataList) - { - SaveUserData(db, internalUserId, userItemData.Key, userItemData); - } - }, TransactionMode); - } + SaveUserData(db, internalUserId, userItemData.Key, userItemData); + } + }, TransactionMode); } } /// <summary> /// Gets the user data. /// </summary> - /// <param name="internalUserId">The user id.</param> + /// <param name="userId">The user id.</param> /// <param name="key">The key.</param> /// <returns>Task{UserItemData}.</returns> /// <exception cref="ArgumentNullException"> /// userId /// or - /// key + /// key. /// </exception> - public UserItemData GetUserData(long internalUserId, string key) + public UserItemData GetUserData(long userId, string key) { - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } + if (string.IsNullOrEmpty(key)) { throw new ArgumentNullException(nameof(key)); } - using (WriteLock.Read()) + using (var connection = GetConnection(true)) { - using (var connection = CreateConnection(true)) + using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId")) { - using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId")) - { - statement.TryBind("@UserId", internalUserId); - statement.TryBind("@Key", key); + statement.TryBind("@UserId", userId); + statement.TryBind("@Key", key); - foreach (var row in statement.ExecuteQuery()) - { - return ReadRow(row); - } + foreach (var row in statement.ExecuteQuery()) + { + return ReadRow(row); } - - return null; } + + return null; } } - public UserItemData GetUserData(long internalUserId, List<string> keys) + public UserItemData GetUserData(long userId, List<string> keys) { if (keys == null) { @@ -318,35 +314,32 @@ namespace Emby.Server.Implementations.Data return null; } - return GetUserData(internalUserId, keys[0]); + return GetUserData(userId, keys[0]); } /// <summary> - /// Return all user-data associated with the given user + /// Return all user-data associated with the given user. /// </summary> - /// <param name="internalUserId"></param> - /// <returns></returns> - public List<UserItemData> GetAllUserData(long internalUserId) + /// <param name="userId">The internal user id.</param> + /// <returns>The list of user item data.</returns> + public List<UserItemData> GetAllUserData(long userId) { - if (internalUserId <= 0) + if (userId <= 0) { - throw new ArgumentNullException(nameof(internalUserId)); + throw new ArgumentNullException(nameof(userId)); } var list = new List<UserItemData>(); - using (WriteLock.Read()) + using (var connection = GetConnection()) { - using (var connection = CreateConnection()) + using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where userId=@UserId")) { - using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where userId=@UserId")) - { - statement.TryBind("@UserId", internalUserId); + statement.TryBind("@UserId", userId); - foreach (var row in statement.ExecuteQuery()) - { - list.Add(ReadRow(row)); - } + foreach (var row in statement.ExecuteQuery()) + { + list.Add(ReadRow(row)); } } } @@ -355,19 +348,20 @@ namespace Emby.Server.Implementations.Data } /// <summary> - /// Read a row from the specified reader into the provided userData object + /// Read a row from the specified reader into the provided userData object. /// </summary> - /// <param name="reader"></param> - private UserItemData ReadRow(IReadOnlyList<IResultSetValue> reader) + /// <param name="reader">The list of result set values.</param> + /// <returns>The user item data.</returns> + private UserItemData ReadRow(IReadOnlyList<ResultSetValue> reader) { var userData = new UserItemData(); userData.Key = reader[0].ToString(); - //userData.UserId = reader[1].ReadGuidFromBlob(); + // 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(); @@ -375,32 +369,32 @@ 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; } + /// <inheritdoc/> + /// <remarks> + /// There is nothing to dispose here since <see cref="BaseSqliteRepository.WriteLock"/> and + /// <see cref="BaseSqliteRepository.WriteConnection"/> are managed by <see cref="SqliteItemRepository"/>. + /// See <see cref="Initialize(IUserManager, SemaphoreSlim, SQLiteDatabaseConnection)"/>. + /// </remarks> protected override void Dispose(bool dispose) { - // handled by library database - } - - protected override void CloseConnection() - { - // handled by library database } } } diff --git a/Emby.Server.Implementations/Data/SqliteUserRepository.cs b/Emby.Server.Implementations/Data/SqliteUserRepository.cs deleted file mode 100644 index db359d7dd..000000000 --- a/Emby.Server.Implementations/Data/SqliteUserRepository.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Serialization; -using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Data -{ - /// <summary> - /// Class SQLiteUserRepository - /// </summary> - public class SqliteUserRepository : BaseSqliteRepository, IUserRepository - { - private readonly IJsonSerializer _jsonSerializer; - - public SqliteUserRepository( - ILoggerFactory loggerFactory, - IServerApplicationPaths appPaths, - IJsonSerializer jsonSerializer) - : base(loggerFactory.CreateLogger(nameof(SqliteUserRepository))) - { - _jsonSerializer = jsonSerializer; - - DbFilePath = Path.Combine(appPaths.DataPath, "users.db"); - } - - /// <summary> - /// Gets the name of the repository - /// </summary> - /// <value>The name.</value> - public string Name => "SQLite"; - - /// <summary> - /// Opens the connection to the database - /// </summary> - /// <returns>Task.</returns> - public void Initialize() - { - using (var connection = CreateConnection()) - { - RunDefaultInitialization(connection); - - var localUsersTableExists = TableExists(connection, "LocalUsersv2"); - - connection.RunQueries(new[] { - "create table if not exists LocalUsersv2 (Id INTEGER PRIMARY KEY, guid GUID NOT NULL, data BLOB NOT NULL)", - "drop index if exists idx_users" - }); - - if (!localUsersTableExists && TableExists(connection, "Users")) - { - TryMigrateToLocalUsersTable(connection); - } - } - } - - private void TryMigrateToLocalUsersTable(ManagedConnection connection) - { - try - { - connection.RunQueries(new[] - { - "INSERT INTO LocalUsersv2 (guid, data) SELECT guid,data from users" - }); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error migrating users database"); - } - } - - /// <summary> - /// Save a user in the repo - /// </summary> - public void CreateUser(User user) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - var serialized = _jsonSerializer.SerializeToBytes(user); - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - using (var statement = db.PrepareStatement("insert into LocalUsersv2 (guid, data) values (@guid, @data)")) - { - statement.TryBind("@guid", user.Id.ToGuidBlob()); - statement.TryBind("@data", serialized); - - statement.MoveNext(); - } - - var createdUser = GetUser(user.Id, false); - - if (createdUser == null) - { - throw new ApplicationException("created user should never be null"); - } - - user.InternalId = createdUser.InternalId; - - }, TransactionMode); - } - } - } - - public void UpdateUser(User user) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - var serialized = _jsonSerializer.SerializeToBytes(user); - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - using (var statement = db.PrepareStatement("update LocalUsersv2 set data=@data where Id=@InternalId")) - { - statement.TryBind("@InternalId", user.InternalId); - statement.TryBind("@data", serialized); - statement.MoveNext(); - } - - }, TransactionMode); - } - } - } - - private User GetUser(Guid guid, bool openLock) - { - using (openLock ? WriteLock.Read() : null) - { - using (var connection = CreateConnection(true)) - { - using (var statement = connection.PrepareStatement("select id,guid,data from LocalUsersv2 where guid=@guid")) - { - statement.TryBind("@guid", guid); - - foreach (var row in statement.ExecuteQuery()) - { - return GetUser(row); - } - } - } - } - - return null; - } - - private User GetUser(IReadOnlyList<IResultSetValue> row) - { - var id = row[0].ToInt64(); - var guid = row[1].ReadGuidFromBlob(); - - using (var stream = new MemoryStream(row[2].ToBlob())) - { - stream.Position = 0; - var user = _jsonSerializer.DeserializeFromStream<User>(stream); - user.InternalId = id; - user.Id = guid; - return user; - } - } - - /// <summary> - /// Retrieve all users from the database - /// </summary> - /// <returns>IEnumerable{User}.</returns> - public List<User> RetrieveAllUsers() - { - var list = new List<User>(); - - using (WriteLock.Read()) - { - using (var connection = CreateConnection(true)) - { - foreach (var row in connection.Query("select id,guid,data from LocalUsersv2")) - { - list.Add(GetUser(row)); - } - } - } - - return list; - } - - /// <summary> - /// Deletes the user. - /// </summary> - /// <param name="user">The user.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">user</exception> - public void DeleteUser(User user) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - using (var statement = db.PrepareStatement("delete from LocalUsersv2 where Id=@id")) - { - statement.TryBind("@id", user.InternalId); - statement.MoveNext(); - } - }, TransactionMode); - } - } - } - } -} diff --git a/Emby.Server.Implementations/Data/SynchronouseMode.cs b/Emby.Server.Implementations/Data/SynchronouseMode.cs new file mode 100644 index 000000000..cde524e2e --- /dev/null +++ b/Emby.Server.Implementations/Data/SynchronouseMode.cs @@ -0,0 +1,30 @@ +namespace Emby.Server.Implementations.Data; + +/// <summary> +/// The disk synchronization mode, controls how aggressively SQLite will write data +/// all the way out to physical storage. +/// </summary> +public enum SynchronousMode +{ + /// <summary> + /// SQLite continues without syncing as soon as it has handed data off to the operating system. + /// </summary> + Off = 0, + + /// <summary> + /// SQLite database engine will still sync at the most critical moments. + /// </summary> + Normal = 1, + + /// <summary> + /// SQLite database engine will use the xSync method of the VFS + /// to ensure that all content is safely written to the disk surface prior to continuing. + /// </summary> + Full = 2, + + /// <summary> + /// EXTRA synchronous is like FULL with the addition that the directory containing a rollback journal + /// is synced after that journal is unlinked to commit a transaction in DELETE mode. + /// </summary> + Extra = 3 +} diff --git a/Emby.Server.Implementations/Data/TempStoreMode.cs b/Emby.Server.Implementations/Data/TempStoreMode.cs new file mode 100644 index 000000000..d2427ce47 --- /dev/null +++ b/Emby.Server.Implementations/Data/TempStoreMode.cs @@ -0,0 +1,23 @@ +namespace Emby.Server.Implementations.Data; + +/// <summary> +/// Storage mode used by temporary database files. +/// </summary> +public enum TempStoreMode +{ + /// <summary> + /// The compile-time C preprocessor macro SQLITE_TEMP_STORE + /// is used to determine where temporary tables and indices are stored. + /// </summary> + Default = 0, + + /// <summary> + /// Temporary tables and indices are stored in a file. + /// </summary> + File = 1, + + /// <summary> + /// Temporary tables and indices are kept in as if they were pure in-memory databases memory. + /// </summary> + Memory = 2 +} diff --git a/Emby.Server.Implementations/Data/TypeMapper.cs b/Emby.Server.Implementations/Data/TypeMapper.cs index 37c952e88..064664e1f 100644 --- a/Emby.Server.Implementations/Data/TypeMapper.cs +++ b/Emby.Server.Implementations/Data/TypeMapper.cs @@ -1,54 +1,36 @@ using System; using System.Collections.Concurrent; using System.Linq; -using MediaBrowser.Model.Reflection; namespace Emby.Server.Implementations.Data { /// <summary> - /// Class TypeMapper + /// Class TypeMapper. /// </summary> public class TypeMapper { - private readonly IAssemblyInfo _assemblyInfo; - /// <summary> - /// This holds all the types in the running assemblies so that we can de-serialize properly when we don't have strong types + /// 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>(); - - public TypeMapper(IAssemblyInfo assemblyInfo) - { - _assemblyInfo = assemblyInfo; - } + private readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>(); /// <summary> /// Gets the type. /// </summary> /// <param name="typeName">Name of the type.</param> /// <returns>Type.</returns> - /// <exception cref="ArgumentNullException"></exception> - public Type GetType(string typeName) + /// <exception cref="ArgumentNullException"><c>typeName</c> is null.</exception> + 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 _assemblyInfo - .GetCurrentAssemblies() - .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 495c3436a..0cfced8be 100644 --- a/Emby.Server.Implementations/Devices/DeviceId.cs +++ b/Emby.Server.Implementations/Devices/DeviceId.cs @@ -1,8 +1,12 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; +using System.Globalization; using System.IO; using System.Text; using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Devices @@ -10,10 +14,19 @@ namespace Emby.Server.Implementations.Devices public class DeviceId { private readonly IApplicationPaths _appPaths; - private readonly ILogger _logger; - + private readonly ILogger<DeviceId> _logger; private readonly object _syncLock = new object(); + private string _id; + + public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory) + { + _appPaths = appPaths; + _logger = loggerFactory.CreateLogger<DeviceId>(); + } + + public string Value => _id ?? (_id = GetDeviceId()); + private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt"); private string GetCachedId() @@ -67,7 +80,7 @@ namespace Emby.Server.Implementations.Devices private static string GetNewId() { - return Guid.NewGuid().ToString("N"); + return Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); } private string GetDeviceId() @@ -82,15 +95,5 @@ namespace Emby.Server.Implementations.Devices return id; } - - private string _id; - - public DeviceId(IApplicationPaths appPaths, ILoggerFactory loggerFactory) - { - _appPaths = appPaths; - _logger = loggerFactory.CreateLogger("SystemId"); - } - - public string Value => _id ?? (_id = GetDeviceId()); } } diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs deleted file mode 100644 index 7d6529a67..000000000 --- a/Emby.Server.Implementations/Devices/DeviceManager.cs +++ /dev/null @@ -1,501 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.Users; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Devices -{ - public class DeviceManager : IDeviceManager - { - private readonly IJsonSerializer _json; - private readonly IUserManager _userManager; - private readonly IFileSystem _fileSystem; - private readonly ILibraryMonitor _libraryMonitor; - private readonly IServerConfigurationManager _config; - private readonly ILibraryManager _libraryManager; - private readonly ILocalizationManager _localizationManager; - - private readonly IAuthenticationRepository _authRepo; - - public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; - public event EventHandler<GenericEventArgs<CameraImageUploadInfo>> CameraImageUploaded; - - private readonly object _cameraUploadSyncLock = new object(); - private readonly object _capabilitiesSyncLock = new object(); - - public DeviceManager( - IAuthenticationRepository authRepo, - IJsonSerializer json, - ILibraryManager libraryManager, - ILocalizationManager localizationManager, - IUserManager userManager, - IFileSystem fileSystem, - ILibraryMonitor libraryMonitor, - IServerConfigurationManager config) - { - _json = json; - _userManager = userManager; - _fileSystem = fileSystem; - _libraryMonitor = libraryMonitor; - _config = config; - _libraryManager = libraryManager; - _localizationManager = localizationManager; - _authRepo = authRepo; - } - - - private Dictionary<string, ClientCapabilities> _capabilitiesCache = new Dictionary<string, ClientCapabilities>(StringComparer.OrdinalIgnoreCase); - public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) - { - var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json"); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - lock (_capabilitiesSyncLock) - { - _capabilitiesCache[deviceId] = capabilities; - - _json.SerializeToFile(capabilities, path); - } - } - - public void UpdateDeviceOptions(string deviceId, DeviceOptions options) - { - _authRepo.UpdateDeviceOptions(deviceId, options); - - if (DeviceOptionsUpdated != null) - { - DeviceOptionsUpdated(this, new GenericEventArgs<Tuple<string, DeviceOptions>>() - { - Argument = new Tuple<string, DeviceOptions>(deviceId, options) - }); - } - } - - public DeviceOptions GetDeviceOptions(string deviceId) - { - return _authRepo.GetDeviceOptions(deviceId); - } - - public ClientCapabilities GetCapabilities(string id) - { - lock (_capabilitiesSyncLock) - { - if (_capabilitiesCache.TryGetValue(id, out var result)) - { - return result; - } - - var path = Path.Combine(GetDevicePath(id), "capabilities.json"); - try - { - return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities(); - } - catch - { - } - } - - return 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 - - }).Items.FirstOrDefault(); - - var device = session == null ? null : ToDeviceInfo(session); - - return device; - } - - public QueryResult<DeviceInfo> GetDevices(DeviceQuery query) - { - var sessions = _authRepo.Get(new AuthenticationInfoQuery - { - //UserId = query.UserId - HasUser = true - - }).Items; - - // TODO: DeviceQuery doesn't seem to be used from client. Not even Swagger. - if (query.SupportsSync.HasValue) - { - var val = query.SupportsSync.Value; - - sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == val).ToArray(); - } - - if (!query.UserId.Equals(Guid.Empty)) - { - var user = _userManager.GetUserById(query.UserId); - - sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)).ToArray(); - } - - var array = sessions.Select(ToDeviceInfo).ToArray(); - - return new QueryResult<DeviceInfo> - { - Items = array, - TotalRecordCount = array.Length - }; - } - - private DeviceInfo ToDeviceInfo(AuthenticationInfo authInfo) - { - var caps = GetCapabilities(authInfo.DeviceId); - - return new DeviceInfo - { - AppName = authInfo.AppName, - AppVersion = authInfo.AppVersion, - Id = authInfo.DeviceId, - LastUserId = authInfo.UserId, - LastUserName = authInfo.UserName, - Name = authInfo.DeviceName, - DateLastActivity = authInfo.DateLastActivity, - IconUrl = caps == null ? null : caps.IconUrl - }; - } - - private string GetDevicesPath() - { - return Path.Combine(_config.ApplicationPaths.DataPath, "devices"); - } - - private string GetDevicePath(string id) - { - return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N")); - } - - public ContentUploadHistory GetCameraUploadHistory(string deviceId) - { - var path = Path.Combine(GetDevicePath(deviceId), "camerauploads.json"); - - lock (_cameraUploadSyncLock) - { - try - { - return _json.DeserializeFromFile<ContentUploadHistory>(path); - } - catch (IOException) - { - return new ContentUploadHistory - { - DeviceId = deviceId - }; - } - } - } - - public async Task AcceptCameraUpload(string deviceId, Stream stream, LocalFileInfo file) - { - var device = GetDevice(deviceId, false); - var uploadPathInfo = GetUploadPath(device); - - var path = uploadPathInfo.Item1; - - if (!string.IsNullOrWhiteSpace(file.Album)) - { - path = Path.Combine(path, _fileSystem.GetValidFilename(file.Album)); - } - - path = Path.Combine(path, file.Name); - path = Path.ChangeExtension(path, MimeTypes.ToExtension(file.MimeType) ?? "jpg"); - - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - await EnsureLibraryFolder(uploadPathInfo.Item2, uploadPathInfo.Item3).ConfigureAwait(false); - - _libraryMonitor.ReportFileSystemChangeBeginning(path); - - try - { - using (var fs = _fileSystem.GetFileStream(path, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) - { - await stream.CopyToAsync(fs).ConfigureAwait(false); - } - - AddCameraUpload(deviceId, file); - } - finally - { - _libraryMonitor.ReportFileSystemChangeComplete(path, true); - } - - if (CameraImageUploaded != null) - { - CameraImageUploaded?.Invoke(this, new GenericEventArgs<CameraImageUploadInfo> - { - Argument = new CameraImageUploadInfo - { - Device = device, - FileInfo = file - } - }); - } - } - - private void AddCameraUpload(string deviceId, LocalFileInfo file) - { - var path = Path.Combine(GetDevicePath(deviceId), "camerauploads.json"); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - lock (_cameraUploadSyncLock) - { - ContentUploadHistory history; - - try - { - history = _json.DeserializeFromFile<ContentUploadHistory>(path); - } - catch (IOException) - { - history = new ContentUploadHistory - { - DeviceId = deviceId - }; - } - - history.DeviceId = deviceId; - - var list = history.FilesUploaded.ToList(); - list.Add(file); - history.FilesUploaded = list.ToArray(); - - _json.SerializeToFile(history, path); - } - } - - internal Task EnsureLibraryFolder(string path, string name) - { - var existingFolders = _libraryManager - .RootFolder - .Children - .OfType<Folder>() - .Where(i => _fileSystem.AreEqual(path, i.Path) || _fileSystem.ContainsSubPath(i.Path, path)) - .ToList(); - - if (existingFolders.Count > 0) - { - return Task.CompletedTask; - } - - Directory.CreateDirectory(path); - - var libraryOptions = new LibraryOptions - { - PathInfos = new[] { new MediaPathInfo { Path = path } }, - EnablePhotos = true, - EnableRealtimeMonitor = false, - SaveLocalMetadata = true - }; - - if (string.IsNullOrWhiteSpace(name)) - { - name = _localizationManager.GetLocalizedString("HeaderCameraUploads"); - } - - return _libraryManager.AddVirtualFolder(name, CollectionType.HomeVideos, libraryOptions, true); - } - - private Tuple<string, string, string> GetUploadPath(DeviceInfo device) - { - var config = _config.GetUploadOptions(); - var path = config.CameraUploadPath; - - if (string.IsNullOrWhiteSpace(path)) - { - path = DefaultCameraUploadsPath; - } - - var topLibraryPath = path; - - if (config.EnableCameraUploadSubfolders) - { - path = Path.Combine(path, _fileSystem.GetValidFilename(device.Name)); - } - - return new Tuple<string, string, string>(path, topLibraryPath, null); - } - - internal string GetUploadsPath() - { - var config = _config.GetUploadOptions(); - var path = config.CameraUploadPath; - - if (string.IsNullOrWhiteSpace(path)) - { - path = DefaultCameraUploadsPath; - } - - return path; - } - - private string DefaultCameraUploadsPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "camerauploads"); - - public bool CanAccessDevice(User user, string deviceId) - { - if (user == null) - { - throw new ArgumentException("user not found"); - } - if (string.IsNullOrEmpty(deviceId)) - { - throw new ArgumentNullException(nameof(deviceId)); - } - - if (!CanAccessDevice(user.Policy, deviceId)) - { - var capabilities = GetCapabilities(deviceId); - - if (capabilities != null && capabilities.SupportsPersistentIdentifier) - { - return false; - } - } - - return true; - } - - private static bool CanAccessDevice(UserPolicy policy, string id) - { - if (policy.EnableAllDevices) - { - return true; - } - - if (policy.IsAdministrator) - { - return true; - } - - return policy.EnabledDevices.Contains(id, StringComparer.OrdinalIgnoreCase); - } - } - - public class DeviceManagerEntryPoint : IServerEntryPoint - { - private readonly DeviceManager _deviceManager; - private readonly IServerConfigurationManager _config; - private ILogger _logger; - - public DeviceManagerEntryPoint(IDeviceManager deviceManager, IServerConfigurationManager config, ILogger logger) - { - _deviceManager = (DeviceManager)deviceManager; - _config = config; - _logger = logger; - } - - public async Task RunAsync() - { - if (!_config.Configuration.CameraUploadUpgraded && _config.Configuration.IsStartupWizardCompleted) - { - var path = _deviceManager.GetUploadsPath(); - - if (Directory.Exists(path)) - { - try - { - await _deviceManager.EnsureLibraryFolder(path, null).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating camera uploads library"); - } - - _config.Configuration.CameraUploadUpgraded = true; - _config.SaveConfiguration(); - } - } - } - - #region IDisposable Support - private bool disposedValue = false; // To detect redundant calls - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - // TODO: dispose managed state (managed objects). - } - - // TODO: free unmanaged resources (unmanaged objects) and override a finalizer below. - // TODO: set large fields to null. - - disposedValue = true; - } - } - - // TODO: override a finalizer only if Dispose(bool disposing) above has code to free unmanaged resources. - // ~DeviceManagerEntryPoint() { - // // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - // Dispose(false); - // } - - // This code added to correctly implement the disposable pattern. - public void Dispose() - { - // Do not change this code. Put cleanup code in Dispose(bool disposing) above. - Dispose(true); - // TODO: uncomment the following line if the finalizer is overridden above. - // GC.SuppressFinalize(this); - } - #endregion - } - - public class DevicesConfigStore : IConfigurationFactory - { - public IEnumerable<ConfigurationStore> GetConfigurations() - { - return new ConfigurationStore[] - { - new ConfigurationStore - { - Key = "devices", - ConfigurationType = typeof(DevicesOptions) - } - }; - } - } - - public static class UploadConfigExtension - { - public static DevicesOptions GetUploadOptions(this IConfigurationManager config) - { - return config.GetConfiguration<DevicesOptions>("devices"); - } - } -} diff --git a/Emby.Server.Implementations/Diagnostics/CommonProcess.cs b/Emby.Server.Implementations/Diagnostics/CommonProcess.cs deleted file mode 100644 index 78b22bda3..000000000 --- a/Emby.Server.Implementations/Diagnostics/CommonProcess.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Diagnostics; - -namespace Emby.Server.Implementations.Diagnostics -{ - public class CommonProcess : IProcess - { - public event EventHandler Exited; - - private readonly ProcessOptions _options; - private readonly Process _process; - - public CommonProcess(ProcessOptions options) - { - _options = options; - - var startInfo = new ProcessStartInfo - { - Arguments = options.Arguments, - FileName = options.FileName, - WorkingDirectory = options.WorkingDirectory, - UseShellExecute = options.UseShellExecute, - CreateNoWindow = options.CreateNoWindow, - RedirectStandardError = options.RedirectStandardError, - RedirectStandardInput = options.RedirectStandardInput, - RedirectStandardOutput = options.RedirectStandardOutput - }; - - startInfo.ErrorDialog = options.ErrorDialog; - - if (options.IsHidden) - { - startInfo.WindowStyle = ProcessWindowStyle.Hidden; - } - - _process = new Process - { - StartInfo = startInfo - }; - - if (options.EnableRaisingEvents) - { - _process.EnableRaisingEvents = true; - _process.Exited += _process_Exited; - } - } - - private bool _hasExited; - private bool HasExited - { - get - { - if (_hasExited) - { - return true; - } - - try - { - _hasExited = _process.HasExited; - } - catch (InvalidOperationException) - { - _hasExited = true; - } - - return _hasExited; - } - } - - private void _process_Exited(object sender, EventArgs e) - { - _hasExited = true; - if (Exited != null) - { - Exited(this, e); - } - } - - public ProcessOptions StartInfo => _options; - - public StreamWriter StandardInput => _process.StandardInput; - - public StreamReader StandardError => _process.StandardError; - - public StreamReader StandardOutput => _process.StandardOutput; - - public int ExitCode => _process.ExitCode; - - public void Start() - { - _process.Start(); - } - - public void Kill() - { - _process.Kill(); - } - - public bool WaitForExit(int timeMs) - { - return _process.WaitForExit(timeMs); - } - - public Task<bool> WaitForExitAsync(int timeMs) - { - //Note: For this function to work correctly, the option EnableRisingEvents needs to be set to true. - - if (HasExited) - { - return Task.FromResult(true); - } - - timeMs = Math.Max(0, timeMs); - - var tcs = new TaskCompletionSource<bool>(); - - var cancellationToken = new CancellationTokenSource(timeMs).Token; - - _process.Exited += (sender, args) => tcs.TrySetResult(true); - - cancellationToken.Register(() => tcs.TrySetResult(HasExited)); - - return tcs.Task; - } - - public void Dispose() - { - _process.Dispose(); - } - } -} diff --git a/Emby.Server.Implementations/Diagnostics/ProcessFactory.cs b/Emby.Server.Implementations/Diagnostics/ProcessFactory.cs deleted file mode 100644 index 14aadaaae..000000000 --- a/Emby.Server.Implementations/Diagnostics/ProcessFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -using MediaBrowser.Model.Diagnostics; - -namespace Emby.Server.Implementations.Diagnostics -{ - public class ProcessFactory : IProcessFactory - { - public IProcess Create(ProcessOptions options) - { - return new CommonProcess(options); - } - } -} diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 2233d3d40..ad76f3d6d 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1,18 +1,21 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Common; using MediaBrowser.Controller.Channels; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; 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.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -21,16 +24,22 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; +using Book = MediaBrowser.Controller.Entities.Book; +using Episode = MediaBrowser.Controller.Entities.TV.Episode; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; +using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; +using Person = MediaBrowser.Controller.Entities.Person; +using Photo = MediaBrowser.Controller.Entities.Photo; +using Season = MediaBrowser.Controller.Entities.TV.Season; +using Series = MediaBrowser.Controller.Entities.TV.Series; namespace Emby.Server.Implementations.Dto { public class DtoService : IDtoService { - private readonly ILogger _logger; + private readonly ILogger<DtoService> _logger; private readonly ILibraryManager _libraryManager; private readonly IUserDataManager _userDataRepository; private readonly IItemRepository _itemRepo; @@ -39,21 +48,21 @@ namespace Emby.Server.Implementations.Dto private readonly IProviderManager _providerManager; private readonly IApplicationHost _appHost; - private readonly Func<IMediaSourceManager> _mediaSourceManager; - private readonly Func<ILiveTvManager> _livetvManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly Lazy<ILiveTvManager> _livetvManagerFactory; public DtoService( - ILoggerFactory loggerFactory, + ILogger<DtoService> logger, ILibraryManager libraryManager, IUserDataManager userDataRepository, IItemRepository itemRepo, IImageProcessor imageProcessor, IProviderManager providerManager, IApplicationHost appHost, - Func<IMediaSourceManager> mediaSourceManager, - Func<ILiveTvManager> livetvManager) + IMediaSourceManager mediaSourceManager, + Lazy<ILiveTvManager> livetvManagerFactory) { - _logger = loggerFactory.CreateLogger(nameof(DtoService)); + _logger = logger; _libraryManager = libraryManager; _userDataRepository = userDataRepository; _itemRepo = itemRepo; @@ -61,64 +70,33 @@ namespace Emby.Server.Implementations.Dto _providerManager = providerManager; _appHost = appHost; _mediaSourceManager = mediaSourceManager; - _livetvManager = livetvManager; - } - - /// <summary> - /// Converts a BaseItem to a DTOBaseItem - /// </summary> - /// <param name="item">The item.</param> - /// <param name="fields">The fields.</param> - /// <param name="user">The user.</param> - /// <param name="owner">The owner.</param> - /// <returns>Task{DtoBaseItem}.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public BaseItemDto GetBaseItemDto(BaseItem item, ItemFields[] fields, User user = null, BaseItem owner = null) - { - var options = new DtoOptions - { - Fields = fields - }; - - return GetBaseItemDto(item, options, user, owner); - } - - public BaseItemDto[] GetBaseItemDtos(List<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null) - { - return GetBaseItemDtos(items, items.Count, options, user, owner); + _livetvManagerFactory = livetvManagerFactory; } - public BaseItemDto[] GetBaseItemDtos(BaseItem[] items, DtoOptions options, User user = null, BaseItem owner = null) - { - return GetBaseItemDtos(items, items.Length, options, user, owner); - } + private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; - public BaseItemDto[] GetBaseItemDtos(IEnumerable<BaseItem> items, int itemCount, DtoOptions options, User user = null, BaseItem owner = null) + /// <inheritdoc /> + public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null) { - var returnItems = new BaseItemDto[itemCount]; - var programTuples = new List<Tuple<BaseItem, BaseItemDto>>(); - var channelTuples = new List<Tuple<BaseItemDto, LiveTvChannel>>(); - - var index = 0; - var allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); + var returnItems = new BaseItemDto[items.Count]; + var programTuples = new List<(BaseItem, BaseItemDto)>(); + var channelTuples = new List<(BaseItemDto, LiveTvChannel)>(); - foreach (var item in items) + for (int index = 0; index < items.Count; index++) { - var dto = GetBaseItemDtoInternal(item, options, allCollectionFolders, user, owner); + var item = items[index]; + var dto = GetBaseItemDtoInternal(item, options, user, owner); - var tvChannel = item as LiveTvChannel; - if (tvChannel != null) + if (item is LiveTvChannel tvChannel) { - channelTuples.Add(new Tuple<BaseItemDto, LiveTvChannel>(dto, tvChannel)); + channelTuples.Add((dto, tvChannel)); } else if (item is LiveTvProgram) { - programTuples.Add(new Tuple<BaseItem, BaseItemDto>(item, dto)); + programTuples.Add((item, dto)); } - var byName = item as IItemByName; - - if (byName != null) + if (item is IItemByName byName) { if (options.ContainsField(ItemFields.ItemCounts)) { @@ -136,18 +114,16 @@ namespace Emby.Server.Implementations.Dto } returnItems[index] = dto; - index++; } if (programTuples.Count > 0) { - var task = _livetvManager().AddInfoToProgramDto(programTuples, options.Fields, user); - Task.WaitAll(task); + LivetvManager.AddInfoToProgramDto(programTuples, options.Fields, user).GetAwaiter().GetResult(); } if (channelTuples.Count > 0) { - _livetvManager().AddChannelInfo(channelTuples, options, user); + LivetvManager.AddChannelInfo(channelTuples, options, user); } return returnItems; @@ -155,35 +131,33 @@ namespace Emby.Server.Implementations.Dto public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User user = null, BaseItem owner = null) { - var allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); - var dto = GetBaseItemDtoInternal(item, options, allCollectionFolders, user, owner); - var tvChannel = item as LiveTvChannel; - if (tvChannel != null) + var dto = GetBaseItemDtoInternal(item, options, user, owner); + if (item is LiveTvChannel tvChannel) { - var list = new List<Tuple<BaseItemDto, LiveTvChannel>> { new Tuple<BaseItemDto, LiveTvChannel>(dto, tvChannel) }; - _livetvManager().AddChannelInfo(list, options, user); + var list = new List<(BaseItemDto, LiveTvChannel)>(1) { (dto, tvChannel) }; + LivetvManager.AddChannelInfo(list, options, user); } else if (item is LiveTvProgram) { - var list = new List<Tuple<BaseItem, BaseItemDto>> { new Tuple<BaseItem, BaseItemDto>(item, dto) }; - var task = _livetvManager().AddInfoToProgramDto(list, options.Fields, user); + var list = new List<(BaseItem, BaseItemDto)>(1) { (item, dto) }; + var task = LivetvManager.AddInfoToProgramDto(list, options.Fields, user); Task.WaitAll(task); } - var byName = item as IItemByName; - - if (byName != null) + if (item is IItemByName itemByName + && options.ContainsField(ItemFields.ItemCounts)) { - if (options.ContainsField(ItemFields.ItemCounts)) - { - SetItemByNameInfo(item, dto, GetTaggedItems(byName, user, new DtoOptions(false) - { - EnableImages = false - - }), user); - } - - return dto; + SetItemByNameInfo( + item, + dto, + GetTaggedItems( + itemByName, + user, + new DtoOptions(false) + { + EnableImages = false + }), + user); } return dto; @@ -191,15 +165,15 @@ namespace Emby.Server.Implementations.Dto private static IList<BaseItem> GetTaggedItems(IItemByName byName, User user, DtoOptions options) { - return byName.GetTaggedItems(new InternalItemsQuery(user) - { - Recursive = true, - DtoOptions = options - - }); + return byName.GetTaggedItems( + new InternalItemsQuery(user) + { + Recursive = true, + DtoOptions = options + }); } - private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, List<Folder> allCollectionFolders, User user = null, BaseItem owner = null) + private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User user = null, BaseItem owner = null) { var dto = new BaseItemDto { @@ -225,13 +199,13 @@ 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); } } if (options.ContainsField(ItemFields.DisplayPreferencesId)) { - dto.DisplayPreferencesId = item.DisplayPreferencesId.ToString("N"); + dto.DisplayPreferencesId = item.DisplayPreferencesId.ToString("N", CultureInfo.InvariantCulture); } if (user != null) @@ -239,15 +213,12 @@ namespace Emby.Server.Implementations.Dto AttachUserSpecificInfo(dto, item, user, options); } - var hasMediaSources = item as IHasMediaSources; - if (hasMediaSources != null) + if (item is IHasMediaSources + && options.ContainsField(ItemFields.MediaSources)) { - if (options.ContainsField(ItemFields.MediaSources)) - { - dto.MediaSources = _mediaSourceManager().GetStaticMediaSources(item, true, user).ToArray(); + dto.MediaSources = _mediaSourceManager.GetStaticMediaSources(item, true, user).ToArray(); - NormalizeMediaSourceContainers(dto); - } + NormalizeMediaSourceContainers(dto); } if (options.ContainsField(ItemFields.Studios)) @@ -276,11 +247,11 @@ namespace Emby.Server.Implementations.Dto dto.Etag = item.GetEtag(user); } - var liveTvManager = _livetvManager(); + var liveTvManager = LivetvManager; var activeRecording = liveTvManager.GetActiveRecordingInfo(item.Path); if (activeRecording != null) { - dto.Type = "Recording"; + dto.Type = BaseItemKind.Recording; dto.CanDownload = false; dto.RunTimeTicks = null; @@ -289,6 +260,7 @@ namespace Emby.Server.Implementations.Dto dto.EpisodeTitle = dto.Name; dto.Name = dto.SeriesName; } + liveTvManager.AddInfoToRecordingDto(item, dto, activeRecording, user); } @@ -304,7 +276,8 @@ namespace Emby.Server.Implementations.Dto { continue; } - var containers = container.Split(new[] { ',' }); + + var containers = container.Split(','); if (containers.Length < 2) { continue; @@ -323,6 +296,7 @@ namespace Emby.Server.Implementations.Dto { path = path.TrimStart('.'); } + if (!string.IsNullOrEmpty(path) && containers.Contains(path, StringComparer.OrdinalIgnoreCase)) { fileExtensionContainer = path; @@ -336,8 +310,7 @@ namespace Emby.Server.Implementations.Dto public BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem> taggedItems, User user = null) { - var allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList(); - var dto = GetBaseItemDtoInternal(item, options, allCollectionFolders, user); + var dto = GetBaseItemDtoInternal(item, options, user); if (taggedItems != null && options.ContainsField(ItemFields.ItemCounts)) { @@ -404,7 +377,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.ChildCount)) { - dto.ChildCount = dto.ChildCount ?? GetChildCount(folder, user); + dto.ChildCount ??= GetChildCount(folder, user); } } @@ -418,7 +391,6 @@ namespace Emby.Server.Implementations.Dto dto.DateLastMediaAdded = folder.DateLastMediaAdded; } } - else { if (options.EnableUserData) @@ -434,7 +406,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.BasicSyncInfo)) { - var userCanSync = user != null && user.Policy.EnableContentDownloading; + var userCanSync = user != null && user.HasPermission(PermissionKind.EnableContentDownloading); if (userCanSync && item.SupportsExternalTransfer) { dto.SupportsSync = true; @@ -448,27 +420,17 @@ namespace Emby.Server.Implementations.Dto // Just return something so that apps that are expecting a value won't think the folders are empty if (folder is ICollectionFolder || folder is UserView) { - return new Random().Next(1, 10); + return Random.Shared.Next(1, 10); } return folder.GetChildCount(user); } - /// <summary> - /// Gets client-side Id of a server-side BaseItem - /// </summary> - /// <param name="item">The item.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">item</exception> - public string GetDtoId(BaseItem item) - { - return item.Id.ToString("N"); - } - private static void SetBookProperties(BaseItemDto dto, Book item) { dto.SeriesName = item.SeriesName; } + private static void SetPhotoProperties(BaseItemDto dto, Photo item) { dto.CameraMake = item.CameraMake; @@ -494,16 +456,20 @@ namespace Emby.Server.Implementations.Dto } } + private string GetDtoId(BaseItem item) + { + return item.Id.ToString("N", CultureInfo.InvariantCulture); + } + private void SetMusicVideoProperties(BaseItemDto dto, MusicVideo item) { if (!string.IsNullOrEmpty(item.Album)) { var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery { - IncludeItemTypes = new[] { typeof(MusicAlbum).Name }, + IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = item.Album, Limit = 1 - }); if (parentAlbumIds.Count > 0) @@ -523,19 +489,6 @@ namespace Emby.Server.Implementations.Dto .ToArray(); } - private string GetImageCacheTag(BaseItem item, ImageType type) - { - try - { - return _imageProcessor.GetImageCacheTag(item, type); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting {type} image info", type); - return null; - } - } - private string GetImageCacheTag(BaseItem item, ItemImageInfo image) { try @@ -550,11 +503,10 @@ namespace Emby.Server.Implementations.Dto } /// <summary> - /// Attaches People DTO's to a DTOBaseItem + /// Attaches People DTO's to a DTOBaseItem. /// </summary> /// <param name="dto">The dto.</param> /// <param name="item">The item.</param> - /// <returns>Task.</returns> private void AttachPeople(BaseItemDto dto, BaseItem item) { // Ordering by person type to ensure actors and artists are at the front. @@ -567,22 +519,27 @@ namespace Emby.Server.Implementations.Dto { return 0; } + if (i.IsType(PersonType.GuestStar)) { return 1; } + if (i.IsType(PersonType.Director)) { return 2; } + if (i.IsType(PersonType.Writer)) { return 3; } + if (i.IsType(PersonType.Producer)) { return 4; } + if (i.IsType(PersonType.Composer)) { return 4; @@ -606,7 +563,6 @@ namespace Emby.Server.Implementations.Dto _logger.LogError(ex, "Error getting person {Name}", c); return null; } - }).Where(i => i != null) .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .Select(x => x.First()) @@ -625,8 +581,28 @@ namespace Emby.Server.Implementations.Dto if (dictionary.TryGetValue(person.Name, out Person entity)) { - baseItemPerson.PrimaryImageTag = GetImageCacheTag(entity, ImageType.Primary); - baseItemPerson.Id = entity.Id.ToString("N"); + baseItemPerson.PrimaryImageTag = GetTagAndFillBlurhash(dto, entity, ImageType.Primary); + baseItemPerson.Id = entity.Id.ToString("N", CultureInfo.InvariantCulture); + 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); } } @@ -639,7 +615,6 @@ namespace Emby.Server.Implementations.Dto /// </summary> /// <param name="dto">The dto.</param> /// <param name="item">The item.</param> - /// <returns>Task.</returns> private void AttachStudios(BaseItemDto dto, BaseItem item) { dto.Studios = item.Studios @@ -674,8 +649,66 @@ namespace Emby.Server.Implementations.Dto return _libraryManager.GetGenreId(name); } + private string GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ImageType imageType, int imageIndex = 0) + { + var image = item.GetImageInfo(imageType, imageIndex); + if (image != null) + { + return GetTagAndFillBlurhash(dto, item, image); + } + + return null; + } + + private string GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ItemImageInfo image) + { + var tag = GetImageCacheTag(item, image); + if (!string.IsNullOrEmpty(image.BlurHash)) + { + dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>(); + + if (!dto.ImageBlurHashes.ContainsKey(image.Type)) + { + dto.ImageBlurHashes[image.Type] = new Dictionary<string, string>(); + } + + dto.ImageBlurHashes[image.Type][tag] = image.BlurHash; + } + + return tag; + } + + private string[] GetTagsAndFillBlurhashes(BaseItemDto dto, BaseItem item, ImageType imageType, int limit) + { + return GetTagsAndFillBlurhashes(dto, item, imageType, item.GetImages(imageType).Take(limit).ToList()); + } + + private string[] GetTagsAndFillBlurhashes(BaseItemDto dto, BaseItem item, ImageType imageType, List<ItemImageInfo> images) + { + var tags = GetImageTags(item, images); + var hashes = new Dictionary<string, string>(); + for (int i = 0; i < images.Count; i++) + { + var img = images[i]; + if (!string.IsNullOrEmpty(img.BlurHash)) + { + var tag = tags[i]; + hashes[tag] = img.BlurHash; + } + } + + if (hashes.Count > 0) + { + dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>(); + + dto.ImageBlurHashes[imageType] = hashes; + } + + return tags; + } + /// <summary> - /// Sets simple property values on a DTOBaseItem + /// Sets simple property values on a DTOBaseItem. /// </summary> /// <param name="dto">The dto.</param> /// <param name="item">The item.</param> @@ -694,8 +727,8 @@ namespace Emby.Server.Implementations.Dto dto.LockData = item.IsLocked; dto.ForcedSortName = item.ForcedSortName; } - dto.Container = item.Container; + dto.Container = item.Container; dto.EndDate = item.EndDate; if (options.ContainsField(ItemFields.ExternalUrls)) @@ -714,10 +747,12 @@ namespace Emby.Server.Implementations.Dto dto.AspectRatio = hasAspectRatio.AspectRatio; } + dto.ImageBlurHashes = new Dictionary<ImageType, Dictionary<string, string>>(); + var backdropLimit = options.GetImageLimit(ImageType.Backdrop); if (backdropLimit > 0) { - dto.BackdropImageTags = GetImageTags(item, item.GetImages(ImageType.Backdrop).Take(backdropLimit).ToList()); + dto.BackdropImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Backdrop, backdropLimit); } if (options.ContainsField(ItemFields.ScreenshotImageTags)) @@ -725,7 +760,7 @@ namespace Emby.Server.Implementations.Dto var screenshotLimit = options.GetImageLimit(ImageType.Screenshot); if (screenshotLimit > 0) { - dto.ScreenshotImageTags = GetImageTags(item, item.GetImages(ImageType.Screenshot).Take(screenshotLimit).ToList()); + dto.ScreenshotImageTags = GetTagsAndFillBlurhashes(dto, item, ImageType.Screenshot, screenshotLimit); } } @@ -741,12 +776,11 @@ namespace Emby.Server.Implementations.Dto // Prevent implicitly captured closure var currentItem = item; - foreach (var image in currentItem.ImageInfos.Where(i => !currentItem.AllowsMultipleImages(i.Type)) - .ToList()) + foreach (var image in currentItem.ImageInfos.Where(i => !currentItem.AllowsMultipleImages(i.Type))) { if (options.GetImageLimit(image.Type) > 0) { - var tag = GetImageCacheTag(item, image); + var tag = GetTagAndFillBlurhash(dto, item, image); if (tag != null) { @@ -771,7 +805,7 @@ namespace Emby.Server.Implementations.Dto dto.MediaType = item.MediaType; - if (!(item is LiveTvProgram)) + if (item is not LiveTvProgram) { dto.LocationType = item.LocationType; } @@ -786,14 +820,12 @@ namespace Emby.Server.Implementations.Dto dto.CriticRating = item.CriticRating; - var hasDisplayOrder = item as IHasDisplayOrder; - if (hasDisplayOrder != null) + if (item is IHasDisplayOrder hasDisplayOrder) { dto.DisplayOrder = hasDisplayOrder.DisplayOrder; } - var hasCollectionType = item as IHasCollectionType; - if (hasCollectionType != null) + if (item is IHasCollectionType hasCollectionType) { dto.CollectionType = hasCollectionType.CollectionType; } @@ -860,13 +892,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; @@ -893,43 +922,41 @@ namespace Emby.Server.Implementations.Dto if (albumParent != null) { dto.AlbumId = albumParent.Id; - - dto.AlbumPrimaryImageTag = GetImageCacheTag(albumParent, ImageType.Primary); + dto.AlbumPrimaryImageTag = GetTagAndFillBlurhash(dto, albumParent, ImageType.Primary); } - //if (options.ContainsField(ItemFields.MediaSourceCount)) - //{ + // if (options.ContainsField(ItemFields.MediaSourceCount)) + // { // Songs always have one - //} + // } } - var hasArtist = item as IHasArtist; - if (hasArtist != null) + if (item is IHasArtist hasArtist) { dto.Artists = hasArtist.Artists; - //var artistItems = _libraryManager.GetArtists(new InternalItemsQuery - //{ + // var artistItems = _libraryManager.GetArtists(new InternalItemsQuery + // { // EnableTotalRecordCount = false, - // ItemIds = new[] { item.Id.ToString("N") } - //}); + // ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) } + // }); - //dto.ArtistItems = artistItems.Items + // dto.ArtistItems = artistItems.Items // .Select(i => // { // var artist = i.Item1; // return new NameIdPair // { // Name = artist.Name, - // Id = artist.Id.ToString("N") + // Id = artist.Id.ToString("N", CultureInfo.InvariantCulture) // }; // }) // .ToList(); // Include artists that are not in the database yet, e.g., just added via metadata editor - //var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); + // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); dto.ArtistItems = hasArtist.Artists - //.Except(foundArtists, new DistinctNameComparer()) + // .Except(foundArtists, new DistinctNameComparer()) .Select(i => { // This should not be necessary but we're seeing some cases of it @@ -952,7 +979,6 @@ namespace Emby.Server.Implementations.Dto } return null; - }).Where(i => i != null).ToArray(); } @@ -961,26 +987,26 @@ namespace Emby.Server.Implementations.Dto { dto.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); - //var artistItems = _libraryManager.GetAlbumArtists(new InternalItemsQuery - //{ + // var artistItems = _libraryManager.GetAlbumArtists(new InternalItemsQuery + // { // EnableTotalRecordCount = false, - // ItemIds = new[] { item.Id.ToString("N") } - //}); + // ItemIds = new[] { item.Id.ToString("N", CultureInfo.InvariantCulture) } + // }); - //dto.AlbumArtists = artistItems.Items + // dto.AlbumArtists = artistItems.Items // .Select(i => // { // var artist = i.Item1; // return new NameIdPair // { // Name = artist.Name, - // Id = artist.Id.ToString("N") + // Id = artist.Id.ToString("N", CultureInfo.InvariantCulture) // }; // }) // .ToList(); dto.AlbumArtists = hasAlbumArtist.AlbumArtists - //.Except(foundArtists, new DistinctNameComparer()) + // .Except(foundArtists, new DistinctNameComparer()) .Select(i => { // This should not be necessary but we're seeing some cases of it @@ -1003,13 +1029,11 @@ namespace Emby.Server.Implementations.Dto } return null; - }).Where(i => i != null).ToArray(); } // Add video info - var video = item as Video; - if (video != null) + if (item is Video video) { dto.VideoType = video.VideoType; dto.Video3DFormat = video.Video3DFormat; @@ -1048,9 +1072,7 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.MediaStreams)) { // Add VideoInfo - var iHasMediaSources = item as IHasMediaSources; - - if (iHasMediaSources != null) + if (item is IHasMediaSources) { MediaStream[] mediaStreams; @@ -1062,14 +1084,15 @@ namespace Emby.Server.Implementations.Dto } else { - mediaStreams = dto.MediaSources.Where(i => string.Equals(i.Id, item.Id.ToString("N"), StringComparison.OrdinalIgnoreCase)) + string id = item.Id.ToString("N", CultureInfo.InvariantCulture); + mediaStreams = dto.MediaSources.Where(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.MediaStreams) .ToArray(); } } else { - mediaStreams = _mediaSourceManager().GetStaticMediaSources(item, true).First().MediaStreams.ToArray(); + mediaStreams = _mediaSourceManager.GetStaticMediaSources(item, true)[0].MediaStreams.ToArray(); } dto.MediaStreams = mediaStreams; @@ -1080,27 +1103,23 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.SpecialFeatureCount)) { - if (allExtras == null) - { - allExtras = item.GetExtras().ToArray(); - } - + allExtras = item.GetExtras().ToArray(); dto.SpecialFeatureCount = allExtras.Count(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)); } if (options.ContainsField(ItemFields.LocalTrailerCount)) { - if (allExtras == null) + allExtras ??= item.GetExtras().ToArray(); + dto.LocalTrailerCount = allExtras.Count(i => i.ExtraType == ExtraType.Trailer); + + if (item is IHasTrailers hasTrailers) { - allExtras = item.GetExtras().ToArray(); + dto.LocalTrailerCount += hasTrailers.GetTrailerCount(); } - - dto.LocalTrailerCount = allExtras.Count(i => i.ExtraType.HasValue && i.ExtraType.Value == ExtraType.Trailer); } // Add EpisodeInfo - var episode = item as Episode; - if (episode != null) + if (item is Episode episode) { dto.IndexNumberEnd = episode.IndexNumberEnd; dto.SeriesName = episode.SeriesName; @@ -1118,18 +1137,24 @@ namespace Emby.Server.Implementations.Dto Series episodeSeries = null; - //if (options.ContainsField(ItemFields.SeriesPrimaryImage)) + // this block will add the series poster for episodes without a poster + // TODO maybe remove the if statement entirely + // if (options.ContainsField(ItemFields.SeriesPrimaryImage)) { - episodeSeries = episodeSeries ?? episode.Series; + episodeSeries ??= episode.Series; if (episodeSeries != null) { - dto.SeriesPrimaryImageTag = GetImageCacheTag(episodeSeries, ImageType.Primary); + dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, episodeSeries, ImageType.Primary); + if (dto.ImageTags == null || !dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, episodeSeries); + } } } if (options.ContainsField(ItemFields.SeriesStudio)) { - episodeSeries = episodeSeries ?? episode.Series; + episodeSeries ??= episode.Series; if (episodeSeries != null) { dto.SeriesStudio = episodeSeries.Studios.FirstOrDefault(); @@ -1138,17 +1163,15 @@ namespace Emby.Server.Implementations.Dto } // Add SeriesInfo - var series = item as Series; - if (series != null) + if (item is Series series) { dto.AirDays = series.AirDays; dto.AirTime = series.AirTime; - dto.Status = series.Status.HasValue ? series.Status.Value.ToString() : null; + dto.Status = series.Status?.ToString(); } // Add SeasonInfo - var season = item as Season; - if (season != null) + if (item is Season season) { dto.SeriesName = season.SeriesName; dto.SeriesId = season.SeriesId; @@ -1157,31 +1180,35 @@ namespace Emby.Server.Implementations.Dto if (options.ContainsField(ItemFields.SeriesStudio)) { - series = series ?? season.Series; + series ??= season.Series; if (series != null) { dto.SeriesStudio = series.Studios.FirstOrDefault(); } } - //if (options.ContainsField(ItemFields.SeriesPrimaryImage)) + // this block will add the series poster for seasons without a poster + // TODO maybe remove the if statement entirely + // if (options.ContainsField(ItemFields.SeriesPrimaryImage)) { - series = series ?? season.Series; + series ??= season.Series; if (series != null) { - dto.SeriesPrimaryImageTag = GetImageCacheTag(series, ImageType.Primary); + dto.SeriesPrimaryImageTag = GetTagAndFillBlurhash(dto, series, ImageType.Primary); + if (dto.ImageTags == null || !dto.ImageTags.ContainsKey(ImageType.Primary)) + { + AttachPrimaryImageAspectRatio(dto, series); + } } } } - var musicVideo = item as MusicVideo; - if (musicVideo != null) + if (item is MusicVideo musicVideo) { SetMusicVideoProperties(dto, musicVideo); } - var book = item as Book; - if (book != null) + if (item is Book book) { SetBookProperties(dto, book); } @@ -1221,8 +1248,7 @@ namespace Emby.Server.Implementations.Dto } } - var photo = item as Photo; - if (photo != null) + if (item is Photo photo) { SetPhotoProperties(dto, photo); } @@ -1241,8 +1267,7 @@ namespace Emby.Server.Implementations.Dto private BaseItem GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem) { - var musicAlbum = currentItem as MusicAlbum; - if (musicAlbum != null) + if (currentItem is MusicAlbum musicAlbum) { var artist = musicAlbum.GetMusicArtist(new DtoOptions(false)); if (artist != null) @@ -1253,7 +1278,7 @@ namespace Emby.Server.Implementations.Dto var parent = currentItem.DisplayParent ?? currentItem.GetOwner() ?? currentItem.GetParent(); - if (parent == null && !(originalItem is UserRootFolder) && !(originalItem is UserView) && !(originalItem is AggregateFolder) && !(originalItem is ICollectionFolder) && !(originalItem is Channel)) + if (parent == null && originalItem is not UserRootFolder && originalItem is not UserView && originalItem is not AggregateFolder && originalItem is not ICollectionFolder && originalItem is not Channel) { parent = _libraryManager.GetCollectionFolders(originalItem).FirstOrDefault(); } @@ -1286,9 +1311,12 @@ namespace Emby.Server.Implementations.Dto var imageTags = dto.ImageTags; - while (((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0) || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) || parent is Series) && - (parent = parent ?? (isFirst ? GetImageDisplayParent(item, item) ?? owner : parent)) != null) + while ((!(imageTags != null && imageTags.ContainsKey(ImageType.Logo)) && logoLimit > 0) + || (!(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && artLimit > 0) + || (!(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0) + || parent is Series) { + parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent; if (parent == null) { break; @@ -1303,9 +1331,10 @@ namespace Emby.Server.Implementations.Dto if (image != null) { dto.ParentLogoItemId = GetDtoId(parent); - dto.ParentLogoImageTag = GetImageCacheTag(parent, image); + dto.ParentLogoImageTag = GetTagAndFillBlurhash(dto, parent, image); } } + if (artLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Art)) && dto.ParentArtItemId == null) { var image = allImages.FirstOrDefault(i => i.Type == ImageType.Art); @@ -1313,19 +1342,21 @@ namespace Emby.Server.Implementations.Dto if (image != null) { dto.ParentArtItemId = GetDtoId(parent); - dto.ParentArtImageTag = GetImageCacheTag(parent, image); + dto.ParentArtImageTag = GetTagAndFillBlurhash(dto, parent, image); } } - if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && !(parent is ICollectionFolder) && !(parent is UserView)) + + if (thumbLimit > 0 && !(imageTags != null && imageTags.ContainsKey(ImageType.Thumb)) && (dto.ParentThumbItemId == null || parent is Series) && parent is not ICollectionFolder && parent is not UserView) { var image = allImages.FirstOrDefault(i => i.Type == ImageType.Thumb); if (image != null) { dto.ParentThumbItemId = GetDtoId(parent); - dto.ParentThumbImageTag = GetImageCacheTag(parent, image); + dto.ParentThumbImageTag = GetTagAndFillBlurhash(dto, parent, image); } } + if (backdropLimit > 0 && !((dto.BackdropImageTags != null && dto.BackdropImageTags.Length > 0) || (dto.ParentBackdropImageTags != null && dto.ParentBackdropImageTags.Length > 0))) { var images = allImages.Where(i => i.Type == ImageType.Backdrop).Take(backdropLimit).ToList(); @@ -1333,7 +1364,7 @@ namespace Emby.Server.Implementations.Dto if (images.Count > 0) { dto.ParentBackdropItemId = GetDtoId(parent); - dto.ParentBackdropImageTags = GetImageTags(parent, images); + dto.ParentBackdropImageTags = GetTagsAndFillBlurhashes(dto, parent, ImageType.Backdrop, images); } } @@ -1365,7 +1396,6 @@ namespace Emby.Server.Implementations.Dto /// </summary> /// <param name="dto">The dto.</param> /// <param name="item">The item.</param> - /// <returns>Task.</returns> public void AttachPrimaryImageAspectRatio(IItemDto dto, BaseItem item) { dto.PrimaryImageAspectRatio = GetPrimaryImageAspectRatio(item); @@ -1380,56 +1410,33 @@ namespace Emby.Server.Implementations.Dto return null; } - var supportedEnhancers = _imageProcessor.GetSupportedEnhancers(item, ImageType.Primary); - ImageDimensions size; var defaultAspectRatio = item.GetDefaultPrimaryImageAspectRatio(); if (defaultAspectRatio > 0) { - if (supportedEnhancers.Length == 0) - { - return defaultAspectRatio; - } - - int dummyWidth = 200; - int dummyHeight = Convert.ToInt32(dummyWidth / defaultAspectRatio); - size = new ImageDimensions(dummyWidth, dummyHeight); + return defaultAspectRatio; } - else + + if (!imageInfo.IsLocalFile) { - if (!imageInfo.IsLocalFile) - { - return null; - } + return null; + } - try - { - size = _imageProcessor.GetImageDimensions(item, imageInfo); + try + { + size = _imageProcessor.GetImageDimensions(item, imageInfo); - if (size.Width <= 0 || size.Height <= 0) - { - return null; - } - } - catch (Exception ex) + if (size.Width <= 0 || size.Height <= 0) { - _logger.LogError(ex, "Failed to determine primary image aspect ratio for {0}", imageInfo.Path); return null; } } - - foreach (var enhancer in supportedEnhancers) + catch (Exception ex) { - try - { - size = enhancer.GetEnhancedImageSize(item, ImageType.Primary, 0, size); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in image enhancer: {0}", enhancer.GetType().Name); - } + _logger.LogError(ex, "Failed to determine primary image aspect ratio for {0}", imageInfo.Path); + return null; } var width = size.Width; @@ -1440,7 +1447,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 bbf165d62..c1ce4b557 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -1,34 +1,40 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> + + <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> + <PropertyGroup> + <ProjectGuid>{E383961B-9356-4D5D-8233-9A1079D03055}</ProjectGuid> + </PropertyGroup> <ItemGroup> <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" /> <ProjectReference Include="..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" /> - <ProjectReference Include="..\MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj" /> <ProjectReference Include="..\MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj" /> - <ProjectReference Include="..\SocketHttpListener\SocketHttpListener.csproj" /> <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" /> - <ProjectReference Include="..\Mono.Nat\Mono.Nat.csproj" /> - <ProjectReference Include="..\MediaBrowser.Api\MediaBrowser.Api.csproj" /> <ProjectReference Include="..\MediaBrowser.LocalMetadata\MediaBrowser.LocalMetadata.csproj" /> - <ProjectReference Include="..\OpenSubtitlesHandler\OpenSubtitlesHandler.csproj" /> <ProjectReference Include="..\Emby.Photos\Emby.Photos.csproj" /> <ProjectReference Include="..\Emby.Drawing\Emby.Drawing.csproj" /> - <ProjectReference Include="..\Emby.XmlTv\Emby.XmlTv\Emby.XmlTv.csproj" /> <ProjectReference Include="..\MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj" /> </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="2.2.0" /> - <PackageReference Include="ServiceStack.Text.Core" Version="5.4.0" /> - <PackageReference Include="sharpcompress" Version="0.22.0" /> - <PackageReference Include="SQLitePCL.pretty.netstandard" Version="1.0.0" /> - <PackageReference Include="UTF.Unknown" Version="1.0.0-beta1" /> + <PackageReference Include="DiscUtils.Udf" Version="0.16.13" /> + <PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0-rc.2*" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.0-rc.2*" /> + <PackageReference Include="Mono.Nat" Version="3.0.1" /> + <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.2.2" /> + <PackageReference Include="sharpcompress" Version="0.30.0" /> + <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> + <PackageReference Include="DotNet.Glob" Version="3.1.3" /> </ItemGroup> <ItemGroup> @@ -36,15 +42,24 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> + <NoWarn>AD0001</NoWarn> </PropertyGroup> + <!-- Code Analyzers--> + <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <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> + <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/AutomaticRestartEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/AutomaticRestartEntryPoint.cs deleted file mode 100644 index 19ea09359..000000000 --- a/Emby.Server.Implementations/EntryPoints/AutomaticRestartEntryPoint.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.EntryPoints -{ - public class AutomaticRestartEntryPoint : IServerEntryPoint - { - private readonly IServerApplicationHost _appHost; - private readonly ILogger _logger; - private readonly ITaskManager _iTaskManager; - private readonly ISessionManager _sessionManager; - private readonly IServerConfigurationManager _config; - private readonly ILiveTvManager _liveTvManager; - - private Timer _timer; - - public AutomaticRestartEntryPoint(IServerApplicationHost appHost, ILogger logger, ITaskManager iTaskManager, ISessionManager sessionManager, IServerConfigurationManager config, ILiveTvManager liveTvManager) - { - _appHost = appHost; - _logger = logger; - _iTaskManager = iTaskManager; - _sessionManager = sessionManager; - _config = config; - _liveTvManager = liveTvManager; - } - - public Task RunAsync() - { - if (_appHost.CanSelfRestart) - { - _appHost.HasPendingRestartChanged += _appHost_HasPendingRestartChanged; - } - - return Task.CompletedTask; - } - - void _appHost_HasPendingRestartChanged(object sender, EventArgs e) - { - DisposeTimer(); - - if (_appHost.HasPendingRestart) - { - _timer = new Timer(TimerCallback, null, TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(15)); - } - } - - private async void TimerCallback(object state) - { - if (_config.Configuration.EnableAutomaticRestart) - { - var isIdle = await IsIdle().ConfigureAwait(false); - - if (isIdle) - { - DisposeTimer(); - - _logger.LogInformation("Automatically restarting the system because it is idle and a restart is required."); - - try - { - _appHost.Restart(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error restarting server"); - } - } - } - } - - private async Task<bool> IsIdle() - { - if (_iTaskManager.ScheduledTasks.Any(i => i.State != TaskState.Idle)) - { - return false; - } - - if (_liveTvManager.Services.Count == 1) - { - try - { - var timers = await _liveTvManager.GetTimers(new TimerQuery(), CancellationToken.None).ConfigureAwait(false); - if (timers.Items.Any(i => i.Status == RecordingStatus.InProgress)) - { - return false; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting timers"); - } - } - - var now = DateTime.UtcNow; - - return !_sessionManager.Sessions.Any(i => (now - i.LastActivityDate).TotalMinutes < 30); - } - - public void Dispose() - { - _appHost.HasPendingRestartChanged -= _appHost_HasPendingRestartChanged; - - DisposeTimer(); - } - - private void DisposeTimer() - { - if (_timer != null) - { - _timer.Dispose(); - _timer = null; - } - } - } -} diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs index f26a70586..640754af4 100644 --- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs +++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs @@ -1,224 +1,137 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; using System.Net; +using System.Text; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Net; +using Jellyfin.Networking.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Dlna; -using MediaBrowser.Model.Events; using Microsoft.Extensions.Logging; using Mono.Nat; namespace Emby.Server.Implementations.EntryPoints { + /// <summary> + /// Server entrypoint handling external port forwarding. + /// </summary> public class ExternalPortForwarding : IServerEntryPoint { private readonly IServerApplicationHost _appHost; - private readonly ILogger _logger; - private readonly IHttpClient _httpClient; + private readonly ILogger<ExternalPortForwarding> _logger; private readonly IServerConfigurationManager _config; private readonly IDeviceDiscovery _deviceDiscovery; + private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new ConcurrentDictionary<IPEndPoint, byte>(); + private Timer _timer; + private string _configIdentifier; - private NatManager _natManager; + private bool _disposed = false; - public ExternalPortForwarding(ILoggerFactory loggerFactory, IServerApplicationHost appHost, IServerConfigurationManager config, IDeviceDiscovery deviceDiscovery, IHttpClient httpClient) + /// <summary> + /// Initializes a new instance of the <see cref="ExternalPortForwarding"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="appHost">The application host.</param> + /// <param name="config">The configuration manager.</param> + /// <param name="deviceDiscovery">The device discovery.</param> + public ExternalPortForwarding( + ILogger<ExternalPortForwarding> logger, + IServerApplicationHost appHost, + IServerConfigurationManager config, + IDeviceDiscovery deviceDiscovery) { - _logger = loggerFactory.CreateLogger("PortMapper"); + _logger = logger; _appHost = appHost; _config = config; _deviceDiscovery = deviceDiscovery; - _httpClient = httpClient; - _config.ConfigurationUpdated += _config_ConfigurationUpdated1; - } - - private void _config_ConfigurationUpdated1(object sender, EventArgs e) - { - _config_ConfigurationUpdated(sender, e); } - private string _lastConfigIdentifier; private string GetConfigIdentifier() { - var values = new List<string>(); - var config = _config.Configuration; - - values.Add(config.EnableUPnP.ToString()); - values.Add(config.PublicPort.ToString(CultureInfo.InvariantCulture)); - values.Add(_appHost.HttpPort.ToString(CultureInfo.InvariantCulture)); - values.Add(_appHost.HttpsPort.ToString(CultureInfo.InvariantCulture)); - values.Add(_appHost.EnableHttps.ToString()); - values.Add((config.EnableRemoteAccess).ToString()); - - return string.Join("|", values.ToArray()); + const char Separator = '|'; + var config = _config.GetNetworkConfiguration(); + + return new StringBuilder(32) + .Append(config.EnableUPnP).Append(Separator) + .Append(config.PublicPort).Append(Separator) + .Append(config.PublicHttpsPort).Append(Separator) + .Append(_appHost.HttpPort).Append(Separator) + .Append(_appHost.HttpsPort).Append(Separator) + .Append(_appHost.ListenWithHttps).Append(Separator) + .Append(config.EnableRemoteAccess).Append(Separator) + .ToString(); } - private async void _config_ConfigurationUpdated(object sender, EventArgs e) + private void OnConfigurationUpdated(object sender, EventArgs e) { - if (!string.Equals(_lastConfigIdentifier, GetConfigIdentifier(), StringComparison.OrdinalIgnoreCase)) - { - DisposeNat(); + var oldConfigIdentifier = _configIdentifier; + _configIdentifier = GetConfigIdentifier(); - await RunAsync(); + if (!string.Equals(_configIdentifier, oldConfigIdentifier, StringComparison.OrdinalIgnoreCase)) + { + Stop(); + Start(); } } + /// <inheritdoc /> public Task RunAsync() { - if (_config.Configuration.EnableUPnP && _config.Configuration.EnableRemoteAccess) - { - Start(); - } + Start(); - _config.ConfigurationUpdated -= _config_ConfigurationUpdated; - _config.ConfigurationUpdated += _config_ConfigurationUpdated; + _config.ConfigurationUpdated += OnConfigurationUpdated; return Task.CompletedTask; } private void Start() { - _logger.LogDebug("Starting NAT discovery"); - if (_natManager == null) + var config = _config.GetNetworkConfiguration(); + if (!config.EnableUPnP || !config.EnableRemoteAccess) { - _natManager = new NatManager(_logger, _httpClient); - _natManager.DeviceFound += NatUtility_DeviceFound; - _natManager.StartDiscovery(); + return; } - _timer = new Timer(ClearCreatedRules, null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); + _logger.LogInformation("Starting NAT discovery"); - _deviceDiscovery.DeviceDiscovered += _deviceDiscovery_DeviceDiscovered; + NatUtility.DeviceFound += OnNatUtilityDeviceFound; + NatUtility.StartDiscovery(); - _lastConfigIdentifier = GetConfigIdentifier(); + _timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); } - private async void _deviceDiscovery_DeviceDiscovered(object sender, GenericEventArgs<UpnpDeviceInfo> e) + private void Stop() { - if (_disposed) - { - return; - } + _logger.LogInformation("Stopping NAT discovery"); - var info = e.Argument; + NatUtility.StopDiscovery(); + NatUtility.DeviceFound -= OnNatUtilityDeviceFound; - if (!info.Headers.TryGetValue("USN", out string usn)) usn = string.Empty; - - if (!info.Headers.TryGetValue("NT", out string nt)) nt = string.Empty; - - // Filter device type - if (usn.IndexOf("WANIPConnection:", StringComparison.OrdinalIgnoreCase) == -1 && - nt.IndexOf("WANIPConnection:", StringComparison.OrdinalIgnoreCase) == -1 && - usn.IndexOf("WANPPPConnection:", StringComparison.OrdinalIgnoreCase) == -1 && - nt.IndexOf("WANPPPConnection:", StringComparison.OrdinalIgnoreCase) == -1) - { - return; - } - - var identifier = string.IsNullOrWhiteSpace(usn) ? nt : usn; - - if (info.Location == null) - { - return; - } - - lock (_usnsHandled) - { - if (_usnsHandled.Contains(identifier)) - { - return; - } - _usnsHandled.Add(identifier); - } - - _logger.LogDebug("Found NAT device: " + identifier); - - if (IPAddress.TryParse(info.Location.Host, out var address)) - { - // The Handle method doesn't need the port - var endpoint = new IPEndPoint(address, info.Location.Port); - - IPAddress localAddress = null; - - try - { - var localAddressString = await _appHost.GetLocalApiUrl(CancellationToken.None).ConfigureAwait(false); - - if (Uri.TryCreate(localAddressString, UriKind.Absolute, out var uri)) - { - localAddressString = uri.Host; - - if (!IPAddress.TryParse(localAddressString, out localAddress)) - { - return; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - return; - } - - if (_disposed) - { - return; - } - - // This should never happen, but the Handle method will throw ArgumentNullException if it does - if (localAddress == null) - { - return; - } - - var natManager = _natManager; - if (natManager != null) - { - await natManager.Handle(localAddress, info, endpoint, NatProtocol.Upnp).ConfigureAwait(false); - } - } + _timer?.Dispose(); } - private void ClearCreatedRules(object state) + private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) { - lock (_createdRules) - { - _createdRules.Clear(); - } - lock (_usnsHandled) - { - _usnsHandled.Clear(); - } - } - - void NatUtility_DeviceFound(object sender, DeviceEventArgs e) - { - if (_disposed) - { - return; - } - try { - var device = e.Device; - - CreateRules(device); + await CreateRules(e.Device).ConfigureAwait(false); } - catch + catch (Exception ex) { - // Commenting out because users are reporting problems out of our control - //_logger.LogError(ex, "Error creating port forwarding rules"); + _logger.LogError(ex, "Error creating port forwarding rules"); } } - private List<string> _createdRules = new List<string>(); - private List<string> _usnsHandled = new List<string>(); - private async void CreateRules(INatDevice device) + private Task CreateRules(INatDevice device) { if (_disposed) { @@ -227,90 +140,74 @@ namespace Emby.Server.Implementations.EntryPoints // On some systems the device discovered event seems to fire repeatedly // This check will help ensure we're not trying to port map the same device over and over - var address = device.LocalAddress; - - var addressString = address.ToString(); - - lock (_createdRules) + if (!_createdRules.TryAdd(device.DeviceEndpoint, 0)) { - if (!_createdRules.Contains(addressString)) - { - _createdRules.Add(addressString); - } - else - { - return; - } + return Task.CompletedTask; } - try - { - await CreatePortMap(device, _appHost.HttpPort, _config.Configuration.PublicPort).ConfigureAwait(false); - } - catch (Exception ex) + return Task.WhenAll(CreatePortMaps(device)); + } + + private IEnumerable<Task> CreatePortMaps(INatDevice device) + { + var config = _config.GetNetworkConfiguration(); + yield return CreatePortMap(device, _appHost.HttpPort, config.PublicPort); + + if (_appHost.ListenWithHttps) { - _logger.LogError(ex, "Error creating http port map"); - return; + yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort); } + } + + private async Task CreatePortMap(INatDevice device, int privatePort, int publicPort) + { + _logger.LogDebug( + "Creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}", + privatePort, + publicPort, + device.DeviceEndpoint); try { - await CreatePortMap(device, _appHost.HttpsPort, _config.Configuration.PublicHttpsPort).ConfigureAwait(false); + var mapping = new Mapping(Protocol.Tcp, privatePort, publicPort, 0, _appHost.Name); + await device.CreatePortMapAsync(mapping).ConfigureAwait(false); } catch (Exception ex) { - _logger.LogError(ex, "Error creating https port map"); + _logger.LogError( + ex, + "Error creating port map on local port {LocalPort} to public port {PublicPort} with device {DeviceEndpoint}.", + privatePort, + publicPort, + device.DeviceEndpoint); } } - private Task CreatePortMap(INatDevice device, int privatePort, int publicPort) - { - _logger.LogDebug("Creating port map on local port {0} to public port {1} with device {2}", privatePort, publicPort, device.LocalAddress.ToString()); - - return device.CreatePortMap(new Mapping(Protocol.Tcp, privatePort, publicPort) - { - Description = _appHost.Name - }); - } - - private bool _disposed = false; + /// <inheritdoc /> public void Dispose() { - _disposed = true; - DisposeNat(); + Dispose(true); + GC.SuppressFinalize(this); } - private void DisposeNat() + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) { - _logger.LogDebug("Stopping NAT discovery"); - - if (_timer != null) + if (_disposed) { - _timer.Dispose(); - _timer = null; + return; } - _deviceDiscovery.DeviceDiscovered -= _deviceDiscovery_DeviceDiscovered; + _config.ConfigurationUpdated -= OnConfigurationUpdated; - var natManager = _natManager; + Stop(); - if (natManager != null) - { - _natManager = null; - - using (natManager) - { - try - { - natManager.StopDiscovery(); - natManager.DeviceFound -= NatUtility_DeviceFound; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error stopping NAT Discovery"); - } - } - } + _timer = null; + + _disposed = true; } } } diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 038965647..331de45c1 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -1,9 +1,16 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Events; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -12,8 +19,7 @@ using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints @@ -21,40 +27,34 @@ namespace Emby.Server.Implementations.EntryPoints public class LibraryChangedNotifier : IServerEntryPoint { /// <summary> - /// The _library manager + /// The library update duration. /// </summary> - private readonly ILibraryManager _libraryManager; + private const int LibraryUpdateDuration = 30000; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; - private readonly ILogger _logger; + private readonly ILogger<LibraryChangedNotifier> _logger; /// <summary> - /// The _library changed sync lock + /// The library changed sync lock. /// </summary> private readonly object _libraryChangedSyncLock = new object(); private readonly List<Folder> _foldersAddedTo = new List<Folder>(); private readonly List<Folder> _foldersRemovedFrom = new List<Folder>(); - private readonly List<BaseItem> _itemsAdded = new List<BaseItem>(); private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>(); private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>(); - - /// <summary> - /// Gets or sets the library update timer. - /// </summary> - /// <value>The library update timer.</value> - private Timer LibraryUpdateTimer { get; set; } - - /// <summary> - /// The library update duration - /// </summary> - private const int LibraryUpdateDuration = 30000; - - private readonly IProviderManager _providerManager; - - public LibraryChangedNotifier(ILibraryManager libraryManager, ISessionManager sessionManager, IUserManager userManager, ILogger logger, IProviderManager providerManager) + private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new ConcurrentDictionary<Guid, DateTime>(); + + public LibraryChangedNotifier( + ILibraryManager libraryManager, + ISessionManager sessionManager, + IUserManager userManager, + ILogger<LibraryChangedNotifier> logger, + IProviderManager providerManager) { _libraryManager = libraryManager; _sessionManager = sessionManager; @@ -63,22 +63,26 @@ namespace Emby.Server.Implementations.EntryPoints _providerManager = providerManager; } + /// <summary> + /// Gets or sets the library update timer. + /// </summary> + /// <value>The library update timer.</value> + private Timer LibraryUpdateTimer { get; set; } + public Task RunAsync() { - _libraryManager.ItemAdded += libraryManager_ItemAdded; - _libraryManager.ItemUpdated += libraryManager_ItemUpdated; - _libraryManager.ItemRemoved += libraryManager_ItemRemoved; + _libraryManager.ItemAdded += OnLibraryItemAdded; + _libraryManager.ItemUpdated += OnLibraryItemUpdated; + _libraryManager.ItemRemoved += OnLibraryItemRemoved; - _providerManager.RefreshCompleted += _providerManager_RefreshCompleted; - _providerManager.RefreshStarted += _providerManager_RefreshStarted; - _providerManager.RefreshProgress += _providerManager_RefreshProgress; + _providerManager.RefreshCompleted += OnProviderRefreshCompleted; + _providerManager.RefreshStarted += OnProviderRefreshStarted; + _providerManager.RefreshProgress += OnProviderRefreshProgress; return Task.CompletedTask; } - private Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>(); - - private void _providerManager_RefreshProgress(object sender, GenericEventArgs<Tuple<BaseItem, double>> e) + private void OnProviderRefreshProgress(object sender, GenericEventArgs<Tuple<BaseItem, double>> e) { var item = e.Argument.Item1; @@ -97,15 +101,15 @@ 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"); + dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture); dict["Progress"] = progress.ToString(CultureInfo.InvariantCulture); try { - _sessionManager.SendMessageToAdminSessions("RefreshProgress", dict, CancellationToken.None); + _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, dict, CancellationToken.None); } catch { @@ -115,36 +119,37 @@ namespace Emby.Server.Implementations.EntryPoints foreach (var collectionFolder in collectionFolders) { - var collectionFolderDict = new Dictionary<string, string>(); - collectionFolderDict["ItemId"] = collectionFolder.Id.ToString("N"); - collectionFolderDict["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture); + var collectionFolderDict = new Dictionary<string, string> + { + ["ItemId"] = collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture), + ["Progress"] = (collectionFolder.GetRefreshProgress() ?? 0).ToString(CultureInfo.InvariantCulture) + }; try { - _sessionManager.SendMessageToAdminSessions("RefreshProgress", collectionFolderDict, CancellationToken.None); + _sessionManager.SendMessageToAdminSessions(SessionMessageType.RefreshProgress, collectionFolderDict, CancellationToken.None); } catch { - } } } - private void _providerManager_RefreshStarted(object sender, GenericEventArgs<BaseItem> e) + private void OnProviderRefreshStarted(object sender, GenericEventArgs<BaseItem> e) { - _providerManager_RefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0))); + OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0))); } - private void _providerManager_RefreshCompleted(object sender, GenericEventArgs<BaseItem> e) + private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e) { - _providerManager_RefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100))); + 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) { - var folder = item as Folder; - - if (folder == null) + if (item is not Folder folder) { return false; } @@ -177,7 +182,7 @@ namespace Emby.Server.Implementations.EntryPoints /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void libraryManager_ItemAdded(object sender, ItemChangeEventArgs e) + private void OnLibraryItemAdded(object sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -188,16 +193,18 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer == null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, - Timeout.Infinite); + LibraryUpdateTimer = new Timer( + LibraryUpdateTimerCallback, + null, + LibraryUpdateDuration, + Timeout.Infinite); } else { LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); } - var parent = e.Item.GetParent() as Folder; - if (parent != null) + if (e.Item.GetParent() is Folder parent) { _foldersAddedTo.Add(parent); } @@ -211,7 +218,7 @@ namespace Emby.Server.Implementations.EntryPoints /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void libraryManager_ItemUpdated(object sender, ItemChangeEventArgs e) + private void OnLibraryItemUpdated(object sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -222,8 +229,7 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer == null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, - Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); } else { @@ -239,7 +245,7 @@ namespace Emby.Server.Implementations.EntryPoints /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void libraryManager_ItemRemoved(object sender, ItemChangeEventArgs e) + private void OnLibraryItemRemoved(object sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -250,16 +256,14 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer == null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, - Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); } else { LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); } - var parent = e.Parent as Folder; - if (parent != null) + if (e.Parent is Folder parent) { _foldersRemovedFrom.Add(parent); } @@ -293,7 +297,7 @@ namespace Emby.Server.Implementations.EntryPoints .Select(x => x.First()) .ToList(); - SendChangeNotifications(_itemsAdded.ToList(), itemsUpdated, _itemsRemoved.ToList(), foldersAddedTo, foldersRemovedFrom, CancellationToken.None); + SendChangeNotifications(_itemsAdded.ToList(), itemsUpdated, _itemsRemoved.ToList(), foldersAddedTo, foldersRemovedFrom, CancellationToken.None).GetAwaiter().GetResult(); if (LibraryUpdateTimer != null) { @@ -318,7 +322,7 @@ namespace Emby.Server.Implementations.EntryPoints /// <param name="foldersAddedTo">The folders added to.</param> /// <param name="foldersRemovedFrom">The folders removed from.</param> /// <param name="cancellationToken">The cancellation token.</param> - private async void SendChangeNotifications(List<BaseItem> itemsAdded, List<BaseItem> itemsUpdated, List<BaseItem> itemsRemoved, List<Folder> foldersAddedTo, List<Folder> foldersRemovedFrom, CancellationToken cancellationToken) + private async Task SendChangeNotifications(List<BaseItem> itemsAdded, List<BaseItem> itemsUpdated, List<BaseItem> itemsRemoved, List<Folder> foldersAddedTo, List<Folder> foldersRemovedFrom, CancellationToken cancellationToken) { var userIds = _sessionManager.Sessions .Select(i => i.UserId) @@ -347,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) { @@ -378,17 +382,17 @@ namespace Emby.Server.Implementations.EntryPoints return new LibraryUpdateInfo { - ItemsAdded = itemsAdded.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToArray(), + ItemsAdded = itemsAdded.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)).Distinct().ToArray(), - ItemsUpdated = itemsUpdated.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToArray(), + ItemsUpdated = itemsUpdated.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)).Distinct().ToArray(), - ItemsRemoved = itemsRemoved.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user, true)).Select(i => i.Id.ToString("N")).Distinct().ToArray(), + ItemsRemoved = itemsRemoved.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user, true)).Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)).Distinct().ToArray(), - FoldersAddedTo = foldersAddedTo.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToArray(), + FoldersAddedTo = foldersAddedTo.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)).Distinct().ToArray(), - FoldersRemovedFrom = foldersRemovedFrom.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N")).Distinct().ToArray(), + FoldersRemovedFrom = foldersRemovedFrom.SelectMany(i => TranslatePhysicalItemToUserLibrary(i, user)).Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)).Distinct().ToArray(), - CollectionFolders = GetTopParentIds(newAndRemoved, user, allUserRootChildren).ToArray() + CollectionFolders = GetTopParentIds(newAndRemoved, allUserRootChildren).ToArray() }; } @@ -399,7 +403,7 @@ namespace Emby.Server.Implementations.EntryPoints return false; } - if (item is IItemByName && !(item is MusicArtist)) + if (item is IItemByName && item is not MusicArtist) { return false; } @@ -407,7 +411,7 @@ namespace Emby.Server.Implementations.EntryPoints return item.SourceType == SourceType.Library; } - private IEnumerable<string> GetTopParentIds(List<BaseItem> items, User user, List<Folder> allUserRootChildren) + private IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren) { var list = new List<string>(); @@ -422,7 +426,7 @@ namespace Emby.Server.Implementations.EntryPoints var collectionFolders = _libraryManager.GetCollectionFolders(item, allUserRootChildren); foreach (var folder in allUserRootChildren) { - list.Add(folder.Id.ToString("N")); + list.Add(folder.Id.ToString("N", CultureInfo.InvariantCulture)); } } @@ -432,7 +436,7 @@ namespace Emby.Server.Implementations.EntryPoints /// <summary> /// Translates the physical item to user library. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of item.</typeparam> /// <param name="item">The item.</param> /// <param name="user">The user.</param> /// <param name="includeIfNotFound">if set to <c>true</c> [include if not found].</param> @@ -452,7 +456,7 @@ namespace Emby.Server.Implementations.EntryPoints return new[] { item }; } - return new T[] { }; + return Array.Empty<T>(); } /// <summary> @@ -477,13 +481,13 @@ namespace Emby.Server.Implementations.EntryPoints LibraryUpdateTimer = null; } - _libraryManager.ItemAdded -= libraryManager_ItemAdded; - _libraryManager.ItemUpdated -= libraryManager_ItemUpdated; - _libraryManager.ItemRemoved -= libraryManager_ItemRemoved; + _libraryManager.ItemAdded -= OnLibraryItemAdded; + _libraryManager.ItemUpdated -= OnLibraryItemUpdated; + _libraryManager.ItemRemoved -= OnLibraryItemRemoved; - _providerManager.RefreshCompleted -= _providerManager_RefreshCompleted; - _providerManager.RefreshStarted -= _providerManager_RefreshStarted; - _providerManager.RefreshProgress -= _providerManager_RefreshProgress; + _providerManager.RefreshCompleted -= OnProviderRefreshCompleted; + _providerManager.RefreshStarted -= OnProviderRefreshStarted; + _providerManager.RefreshProgress -= OnProviderRefreshProgress; } } } diff --git a/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs index 0186da9e1..e0ca02d98 100644 --- a/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/RecordingNotifier.cs @@ -1,23 +1,34 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; 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 { - public class RecordingNotifier : IServerEntryPoint + public sealed class RecordingNotifier : IServerEntryPoint { private readonly ILiveTvManager _liveTvManager; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; - private readonly ILogger _logger; + private readonly ILogger<RecordingNotifier> _logger; - public RecordingNotifier(ISessionManager sessionManager, IUserManager userManager, ILogger logger, ILiveTvManager liveTvManager) + public RecordingNotifier( + ISessionManager sessionManager, + IUserManager userManager, + ILogger<RecordingNotifier> logger, + ILiveTvManager liveTvManager) { _sessionManager = sessionManager; _userManager = userManager; @@ -25,47 +36,44 @@ namespace Emby.Server.Implementations.EntryPoints _liveTvManager = liveTvManager; } + /// <inheritdoc /> public Task RunAsync() { - _liveTvManager.TimerCancelled += _liveTvManager_TimerCancelled; - _liveTvManager.SeriesTimerCancelled += _liveTvManager_SeriesTimerCancelled; - _liveTvManager.TimerCreated += _liveTvManager_TimerCreated; - _liveTvManager.SeriesTimerCreated += _liveTvManager_SeriesTimerCreated; + _liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled; + _liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled; + _liveTvManager.TimerCreated += OnLiveTvManagerTimerCreated; + _liveTvManager.SeriesTimerCreated += OnLiveTvManagerSeriesTimerCreated; return Task.CompletedTask; } - private void _liveTvManager_SeriesTimerCreated(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e) + private async void OnLiveTvManagerSeriesTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) { - SendMessage("SeriesTimerCreated", e.Argument); + await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); } - private void _liveTvManager_TimerCreated(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e) + private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) { - SendMessage("TimerCreated", e.Argument); + await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); } - private void _liveTvManager_SeriesTimerCancelled(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e) + private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) { - SendMessage("SeriesTimerCancelled", e.Argument); + await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); } - private void _liveTvManager_TimerCancelled(object sender, MediaBrowser.Model.Events.GenericEventArgs<TimerEventInfo> e) + private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) { - SendMessage("TimerCancelled", e.Argument); + await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); } - private async void SendMessage(string name, TimerEventInfo info) + private async Task SendMessage(SessionMessageType name, TimerEventInfo info) { - var users = _userManager.Users.Where(i => i.Policy.EnableLiveTvAccess).Select(i => i.Id).ToList(); + var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList(); try { - await _sessionManager.SendMessageToUserSessions(users, name, info, CancellationToken.None); - } - catch (ObjectDisposedException) - { - // TODO Log exception or Investigate and properly fix. + await _sessionManager.SendMessageToUserSessions(users, name, info, CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { @@ -73,12 +81,13 @@ namespace Emby.Server.Implementations.EntryPoints } } + /// <inheritdoc /> public void Dispose() { - _liveTvManager.TimerCancelled -= _liveTvManager_TimerCancelled; - _liveTvManager.SeriesTimerCancelled -= _liveTvManager_SeriesTimerCancelled; - _liveTvManager.TimerCreated -= _liveTvManager_TimerCreated; - _liveTvManager.SeriesTimerCreated -= _liveTvManager_SeriesTimerCreated; + _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; + _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; + _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; + _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; } } } diff --git a/Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs b/Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs deleted file mode 100644 index b7565adec..000000000 --- a/Emby.Server.Implementations/EntryPoints/RefreshUsersMetadata.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.EntryPoints -{ - /// <summary> - /// Class RefreshUsersMetadata - /// </summary> - public class RefreshUsersMetadata : IScheduledTask, IConfigurableScheduledTask - { - private readonly ILogger _logger; - /// <summary> - /// The _user manager - /// </summary> - private readonly IUserManager _userManager; - - private IFileSystem _fileSystem; - - public string Name => "Refresh Users"; - - public string Key => "RefreshUsers"; - - public string Description => "Refresh user infos"; - - public string Category => "Library"; - - public bool IsHidden => true; - - public bool IsEnabled => true; - - public bool IsLogged => true; - - /// <summary> - /// Initializes a new instance of the <see cref="RefreshUsersMetadata" /> class. - /// </summary> - public RefreshUsersMetadata(ILogger logger, IUserManager userManager, IFileSystem fileSystem) - { - _logger = logger; - _userManager = userManager; - _fileSystem = fileSystem; - } - - public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) - { - var users = _userManager.Users.ToList(); - - foreach (var user in users) - { - cancellationToken.ThrowIfCancellationRequested(); - - await user.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), cancellationToken).ConfigureAwait(false); - } - } - - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new List<TaskTriggerInfo> - { - new TaskTriggerInfo - { - IntervalTicks = TimeSpan.FromDays(1).Ticks, - Type = TaskTriggerInfo.TriggerInterval - } - }; - } - } -} diff --git a/Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs b/Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs deleted file mode 100644 index 091dd6a45..000000000 --- a/Emby.Server.Implementations/EntryPoints/ServerEventNotifier.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Common.Updates; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Tasks; - -namespace Emby.Server.Implementations.EntryPoints -{ - /// <summary> - /// Class WebSocketEvents - /// </summary> - public class ServerEventNotifier : IServerEntryPoint - { - /// <summary> - /// The _user manager - /// </summary> - private readonly IUserManager _userManager; - - /// <summary> - /// The _installation manager - /// </summary> - private readonly IInstallationManager _installationManager; - - /// <summary> - /// The _kernel - /// </summary> - private readonly IServerApplicationHost _appHost; - - /// <summary> - /// The _task manager - /// </summary> - private readonly ITaskManager _taskManager; - - private readonly ISessionManager _sessionManager; - - public ServerEventNotifier(IServerApplicationHost appHost, IUserManager userManager, IInstallationManager installationManager, ITaskManager taskManager, ISessionManager sessionManager) - { - _userManager = userManager; - _installationManager = installationManager; - _appHost = appHost; - _taskManager = taskManager; - _sessionManager = sessionManager; - } - - public Task RunAsync() - { - _userManager.UserDeleted += userManager_UserDeleted; - _userManager.UserUpdated += userManager_UserUpdated; - _userManager.UserPolicyUpdated += _userManager_UserPolicyUpdated; - _userManager.UserConfigurationUpdated += _userManager_UserConfigurationUpdated; - - _appHost.HasPendingRestartChanged += kernel_HasPendingRestartChanged; - - _installationManager.PluginUninstalled += InstallationManager_PluginUninstalled; - _installationManager.PackageInstalling += _installationManager_PackageInstalling; - _installationManager.PackageInstallationCancelled += _installationManager_PackageInstallationCancelled; - _installationManager.PackageInstallationCompleted += _installationManager_PackageInstallationCompleted; - _installationManager.PackageInstallationFailed += _installationManager_PackageInstallationFailed; - - _taskManager.TaskCompleted += _taskManager_TaskCompleted; - - return Task.CompletedTask; - } - - void _installationManager_PackageInstalling(object sender, InstallationEventArgs e) - { - SendMessageToAdminSessions("PackageInstalling", e.InstallationInfo); - } - - void _installationManager_PackageInstallationCancelled(object sender, InstallationEventArgs e) - { - SendMessageToAdminSessions("PackageInstallationCancelled", e.InstallationInfo); - } - - void _installationManager_PackageInstallationCompleted(object sender, InstallationEventArgs e) - { - SendMessageToAdminSessions("PackageInstallationCompleted", e.InstallationInfo); - } - - void _installationManager_PackageInstallationFailed(object sender, InstallationFailedEventArgs e) - { - SendMessageToAdminSessions("PackageInstallationFailed", e.InstallationInfo); - } - - void _taskManager_TaskCompleted(object sender, TaskCompletionEventArgs e) - { - SendMessageToAdminSessions("ScheduledTaskEnded", e.Result); - } - - /// <summary> - /// Installations the manager_ plugin uninstalled. - /// </summary> - /// <param name="sender">The sender.</param> - /// <param name="e">The e.</param> - void InstallationManager_PluginUninstalled(object sender, GenericEventArgs<IPlugin> e) - { - SendMessageToAdminSessions("PluginUninstalled", e.Argument.GetPluginInfo()); - } - - /// <summary> - /// Handles the HasPendingRestartChanged event of the kernel control. - /// </summary> - /// <param name="sender">The source of the event.</param> - /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param> - void kernel_HasPendingRestartChanged(object sender, EventArgs e) - { - _sessionManager.SendRestartRequiredNotification(CancellationToken.None); - } - - /// <summary> - /// Users the manager_ user updated. - /// </summary> - /// <param name="sender">The sender.</param> - /// <param name="e">The e.</param> - void userManager_UserUpdated(object sender, GenericEventArgs<User> e) - { - var dto = _userManager.GetUserDto(e.Argument); - - SendMessageToUserSession(e.Argument, "UserUpdated", dto); - } - - /// <summary> - /// Users the manager_ user deleted. - /// </summary> - /// <param name="sender">The sender.</param> - /// <param name="e">The e.</param> - void userManager_UserDeleted(object sender, GenericEventArgs<User> e) - { - SendMessageToUserSession(e.Argument, "UserDeleted", e.Argument.Id.ToString("N")); - } - - void _userManager_UserPolicyUpdated(object sender, GenericEventArgs<User> e) - { - var dto = _userManager.GetUserDto(e.Argument); - - SendMessageToUserSession(e.Argument, "UserPolicyUpdated", dto); - } - - void _userManager_UserConfigurationUpdated(object sender, GenericEventArgs<User> e) - { - var dto = _userManager.GetUserDto(e.Argument); - - SendMessageToUserSession(e.Argument, "UserConfigurationUpdated", dto); - } - - private async void SendMessageToAdminSessions<T>(string name, T data) - { - try - { - await _sessionManager.SendMessageToAdminSessions(name, data, CancellationToken.None); - } - catch (Exception) - { - - } - } - - private async void SendMessageToUserSession<T>(User user, string name, T data) - { - try - { - await _sessionManager.SendMessageToUserSessions(new List<Guid> { user.Id }, name, data, CancellationToken.None); - } - catch (Exception) - { - - } - } - - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() - { - Dispose(true); - } - - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) - { - if (dispose) - { - _userManager.UserDeleted -= userManager_UserDeleted; - _userManager.UserUpdated -= userManager_UserUpdated; - _userManager.UserPolicyUpdated -= _userManager_UserPolicyUpdated; - _userManager.UserConfigurationUpdated -= _userManager_UserConfigurationUpdated; - - _installationManager.PluginUninstalled -= InstallationManager_PluginUninstalled; - _installationManager.PackageInstalling -= _installationManager_PackageInstalling; - _installationManager.PackageInstallationCancelled -= _installationManager_PackageInstallationCancelled; - _installationManager.PackageInstallationCompleted -= _installationManager_PackageInstallationCompleted; - _installationManager.PackageInstallationFailed -= _installationManager_PackageInstallationFailed; - - _appHost.HasPendingRestartChanged -= kernel_HasPendingRestartChanged; - } - } - } -} diff --git a/Emby.Server.Implementations/EntryPoints/StartupWizard.cs b/Emby.Server.Implementations/EntryPoints/StartupWizard.cs deleted file mode 100644 index 8be6db87d..000000000 --- a/Emby.Server.Implementations/EntryPoints/StartupWizard.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Threading.Tasks; -using Emby.Server.Implementations.Browser; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Plugins; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.EntryPoints -{ - /// <summary> - /// Class StartupWizard - /// </summary> - public class StartupWizard : IServerEntryPoint - { - /// <summary> - /// The _app host - /// </summary> - private readonly IServerApplicationHost _appHost; - /// <summary> - /// The _user manager - /// </summary> - private readonly ILogger _logger; - - private IServerConfigurationManager _config; - - public StartupWizard(IServerApplicationHost appHost, ILogger logger, IServerConfigurationManager config) - { - _appHost = appHost; - _logger = logger; - _config = config; - } - - /// <summary> - /// Runs this instance. - /// </summary> - public Task RunAsync() - { - if (!_appHost.CanLaunchWebBrowser) - { - return Task.CompletedTask; - } - - if (!_config.Configuration.IsStartupWizardCompleted) - { - BrowserLauncher.OpenWebApp(_appHost); - } - else if (_config.Configuration.AutoRunWebApp) - { - var options = ((ApplicationHost)_appHost).StartupOptions; - - if (!options.NoAutoRunWebApp) - { - BrowserLauncher.OpenWebApp(_appHost); - } - } - - return Task.CompletedTask; - } - - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs index 5b90dc1fb..feaccf9fa 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -1,88 +1,95 @@ using System; +using System.Net.Sockets; +using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Udp; using MediaBrowser.Controller; using MediaBrowser.Controller.Plugins; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints { /// <summary> - /// Class UdpServerEntryPoint + /// Class UdpServerEntryPoint. /// </summary> - public class UdpServerEntryPoint : IServerEntryPoint + public sealed class UdpServerEntryPoint : IServerEntryPoint { /// <summary> - /// Gets or sets the UDP server. + /// The port of the UDP server. /// </summary> - /// <value>The UDP server.</value> - private UdpServer UdpServer { get; set; } + public const int PortNumber = 7359; /// <summary> - /// The _logger + /// The logger. /// </summary> - private readonly ILogger _logger; - private readonly ISocketFactory _socketFactory; + private readonly ILogger<UdpServerEntryPoint> _logger; private readonly IServerApplicationHost _appHost; - private readonly IJsonSerializer _json; + private readonly IConfiguration _config; - public const int PortNumber = 7359; + /// <summary> + /// The UDP server. + /// </summary> + private UdpServer? _udpServer; + private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private bool _disposed = false; /// <summary> /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class. /// </summary> - public UdpServerEntryPoint(ILogger logger, IServerApplicationHost appHost, IJsonSerializer json, ISocketFactory socketFactory) + /// <param name="logger">Instance of the <see cref="ILogger{UdpServerEntryPoint}"/> interface.</param> + /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> + public UdpServerEntryPoint( + ILogger<UdpServerEntryPoint> logger, + IServerApplicationHost appHost, + IConfiguration configuration) { _logger = logger; _appHost = appHost; - _json = json; - _socketFactory = socketFactory; + _config = configuration; } - /// <summary> - /// Runs this instance. - /// </summary> + /// <inheritdoc /> public Task RunAsync() { - var udpServer = new UdpServer(_logger, _appHost, _json, _socketFactory); + CheckDisposed(); try { - udpServer.Start(PortNumber); - - UdpServer = udpServer; + _udpServer = new UdpServer(_logger, _appHost, _config, PortNumber); + _udpServer.Start(_cancellationTokenSource.Token); } - catch (Exception ex) + catch (SocketException ex) { - _logger.LogError(ex, "Failed to start UDP Server"); + _logger.LogWarning(ex, "Unable to start AutoDiscovery listener on UDP port {PortNumber}", PortNumber); } return Task.CompletedTask; } - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() + private void CheckDisposed() { - Dispose(true); + if (_disposed) + { + throw new ObjectDisposedException(this.GetType().Name); + } } - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) + /// <inheritdoc /> + public void Dispose() { - if (dispose) + if (_disposed) { - if (UdpServer != null) - { - UdpServer.Dispose(); - } + return; } + + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + _udpServer?.Dispose(); + _udpServer = null; + + _disposed = true; } } } diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index a5badacee..d3bcd5e13 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -1,5 +1,8 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -8,41 +11,38 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Session; -using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints { - public class UserDataChangeNotifier : IServerEntryPoint + public sealed class UserDataChangeNotifier : IServerEntryPoint { + private const int UpdateDuration = 500; + private readonly ISessionManager _sessionManager; - private readonly ILogger _logger; private readonly IUserDataManager _userDataManager; private readonly IUserManager _userManager; - private readonly object _syncLock = new object(); - private Timer UpdateTimer { get; set; } - private const int UpdateDuration = 500; - private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new Dictionary<Guid, List<BaseItem>>(); - public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, ILogger logger, IUserManager userManager) + private readonly object _syncLock = new object(); + private Timer? _updateTimer; + + public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, IUserManager userManager) { _userDataManager = userDataManager; _sessionManager = sessionManager; - _logger = logger; _userManager = userManager; } public Task RunAsync() { - _userDataManager.UserDataSaved += _userDataManager_UserDataSaved; + _userDataManager.UserDataSaved += OnUserDataManagerUserDataSaved; return Task.CompletedTask; } - void _userDataManager_UserDataSaved(object sender, UserDataSaveEventArgs e) + private void OnUserDataManagerUserDataSaved(object? sender, UserDataSaveEventArgs e) { if (e.SaveReason == UserDataSaveReason.PlaybackProgress) { @@ -51,17 +51,20 @@ namespace Emby.Server.Implementations.EntryPoints lock (_syncLock) { - if (UpdateTimer == null) + if (_updateTimer == null) { - UpdateTimer = new Timer(UpdateTimerCallback, null, UpdateDuration, - Timeout.Infinite); + _updateTimer = new Timer( + UpdateTimerCallback, + null, + UpdateDuration, + Timeout.Infinite); } else { - UpdateTimer.Change(UpdateDuration, Timeout.Infinite); + _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; @@ -84,7 +87,7 @@ namespace Emby.Server.Implementations.EntryPoints } } - private void UpdateTimerCallback(object state) + private void UpdateTimerCallback(object? state) { lock (_syncLock) { @@ -94,10 +97,10 @@ namespace Emby.Server.Implementations.EntryPoints var task = SendNotifications(changes, CancellationToken.None); - if (UpdateTimer != null) + if (_updateTimer != null) { - UpdateTimer.Dispose(); - UpdateTimer = null; + _updateTimer.Dispose(); + _updateTimer = null; } } } @@ -112,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) @@ -125,12 +128,12 @@ namespace Emby.Server.Implementations.EntryPoints .Select(i => { var dto = _userDataManager.GetUserDataDto(i, user); - dto.ItemId = i.Id.ToString("N"); + dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture); return dto; }) .ToArray(); - var userIdString = userId.ToString("N"); + var userIdString = userId.ToString("N", CultureInfo.InvariantCulture); return new UserDataChangeInfo { @@ -142,13 +145,13 @@ namespace Emby.Server.Implementations.EntryPoints public void Dispose() { - if (UpdateTimer != null) + if (_updateTimer != null) { - UpdateTimer.Dispose(); - UpdateTimer = null; + _updateTimer.Dispose(); + _updateTimer = null; } - _userDataManager.UserDataSaved -= _userDataManager_UserDataSaved; + _userDataManager.UserDataSaved -= OnUserDataManagerUserDataSaved; } } } diff --git a/Emby.Server.Implementations/EnvironmentInfo/EnvironmentInfo.cs b/Emby.Server.Implementations/EnvironmentInfo/EnvironmentInfo.cs deleted file mode 100644 index c8104150d..000000000 --- a/Emby.Server.Implementations/EnvironmentInfo/EnvironmentInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using MediaBrowser.Model.System; - -namespace Emby.Server.Implementations.EnvironmentInfo -{ - public class EnvironmentInfo : IEnvironmentInfo - { - public EnvironmentInfo(MediaBrowser.Model.System.OperatingSystem operatingSystem) - { - OperatingSystem = operatingSystem; - } - - public MediaBrowser.Model.System.OperatingSystem OperatingSystem { get; private set; } - - public string OperatingSystemName - { - get - { - switch (OperatingSystem) - { - case MediaBrowser.Model.System.OperatingSystem.Android: return "Android"; - case MediaBrowser.Model.System.OperatingSystem.BSD: return "BSD"; - case MediaBrowser.Model.System.OperatingSystem.Linux: return "Linux"; - case MediaBrowser.Model.System.OperatingSystem.OSX: return "macOS"; - case MediaBrowser.Model.System.OperatingSystem.Windows: return "Windows"; - default: throw new Exception($"Unknown OS {OperatingSystem}"); - } - } - } - - public string OperatingSystemVersion => Environment.OSVersion.Version.ToString() + " " + Environment.OSVersion.ServicePack.ToString(); - - public Architecture SystemArchitecture => RuntimeInformation.OSArchitecture; - } -} diff --git a/Emby.Server.Implementations/FFMpeg/FFMpegInfo.cs b/Emby.Server.Implementations/FFMpeg/FFMpegInfo.cs deleted file mode 100644 index 60cd7b3d7..000000000 --- a/Emby.Server.Implementations/FFMpeg/FFMpegInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Emby.Server.Implementations.FFMpeg -{ - /// <summary> - /// Class FFMpegInfo - /// </summary> - public class FFMpegInfo - { - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - public string EncoderPath { get; set; } - /// <summary> - /// Gets or sets the probe path. - /// </summary> - /// <value>The probe path.</value> - public string ProbePath { get; set; } - /// <summary> - /// Gets or sets the version. - /// </summary> - /// <value>The version.</value> - public string Version { get; set; } - } -} diff --git a/Emby.Server.Implementations/FFMpeg/FFMpegInstallInfo.cs b/Emby.Server.Implementations/FFMpeg/FFMpegInstallInfo.cs deleted file mode 100644 index fa9cb5e01..000000000 --- a/Emby.Server.Implementations/FFMpeg/FFMpegInstallInfo.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Emby.Server.Implementations.FFMpeg -{ - public class FFMpegInstallInfo - { - public string Version { get; set; } - public string FFMpegFilename { get; set; } - public string FFProbeFilename { get; set; } - public string ArchiveType { get; set; } - - public FFMpegInstallInfo() - { - Version = "Path"; - FFMpegFilename = "ffmpeg"; - FFProbeFilename = "ffprobe"; - } - } -} diff --git a/Emby.Server.Implementations/FFMpeg/FFMpegLoader.cs b/Emby.Server.Implementations/FFMpeg/FFMpegLoader.cs deleted file mode 100644 index bbf51dd24..000000000 --- a/Emby.Server.Implementations/FFMpeg/FFMpegLoader.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.IO; - -namespace Emby.Server.Implementations.FFMpeg -{ - public class FFMpegLoader - { - private readonly IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private readonly FFMpegInstallInfo _ffmpegInstallInfo; - - public FFMpegLoader(IApplicationPaths appPaths, IFileSystem fileSystem, FFMpegInstallInfo ffmpegInstallInfo) - { - _appPaths = appPaths; - _fileSystem = fileSystem; - _ffmpegInstallInfo = ffmpegInstallInfo; - } - - public FFMpegInfo GetFFMpegInfo(IStartupOptions options) - { - var customffMpegPath = options.FFmpegPath; - var customffProbePath = options.FFprobePath; - - if (!string.IsNullOrWhiteSpace(customffMpegPath) && !string.IsNullOrWhiteSpace(customffProbePath)) - { - return new FFMpegInfo - { - ProbePath = customffProbePath, - EncoderPath = customffMpegPath, - Version = "external" - }; - } - - var downloadInfo = _ffmpegInstallInfo; - - var prebuiltFolder = _appPaths.ProgramSystemPath; - var prebuiltffmpeg = Path.Combine(prebuiltFolder, downloadInfo.FFMpegFilename); - var prebuiltffprobe = Path.Combine(prebuiltFolder, downloadInfo.FFProbeFilename); - if (File.Exists(prebuiltffmpeg) && File.Exists(prebuiltffprobe)) - { - return new FFMpegInfo - { - ProbePath = prebuiltffprobe, - EncoderPath = prebuiltffmpeg, - Version = "external" - }; - } - - var version = downloadInfo.Version; - - if (string.Equals(version, "0", StringComparison.OrdinalIgnoreCase)) - { - return new FFMpegInfo(); - } - - var rootEncoderPath = Path.Combine(_appPaths.ProgramDataPath, "ffmpeg"); - var versionedDirectoryPath = Path.Combine(rootEncoderPath, version); - - var info = new FFMpegInfo - { - ProbePath = Path.Combine(versionedDirectoryPath, downloadInfo.FFProbeFilename), - EncoderPath = Path.Combine(versionedDirectoryPath, downloadInfo.FFMpegFilename), - Version = version - }; - - Directory.CreateDirectory(versionedDirectoryPath); - - var excludeFromDeletions = new List<string> { versionedDirectoryPath }; - - if (!File.Exists(info.ProbePath) || !File.Exists(info.EncoderPath)) - { - // ffmpeg not present. See if there's an older version we can start with - var existingVersion = GetExistingVersion(info, rootEncoderPath); - - // No older version. Need to download and block until complete - if (existingVersion == null) - { - return new FFMpegInfo(); - } - else - { - info = existingVersion; - versionedDirectoryPath = Path.GetDirectoryName(info.EncoderPath); - excludeFromDeletions.Add(versionedDirectoryPath); - } - } - - // Allow just one of these to be overridden, if desired. - if (!string.IsNullOrWhiteSpace(customffMpegPath)) - { - info.EncoderPath = customffMpegPath; - } - if (!string.IsNullOrWhiteSpace(customffProbePath)) - { - info.ProbePath = customffProbePath; - } - - return info; - } - - private FFMpegInfo GetExistingVersion(FFMpegInfo info, string rootEncoderPath) - { - var encoderFilename = Path.GetFileName(info.EncoderPath); - var probeFilename = Path.GetFileName(info.ProbePath); - - foreach (var directory in _fileSystem.GetDirectoryPaths(rootEncoderPath)) - { - var allFiles = _fileSystem.GetFilePaths(directory, true).ToList(); - - var encoder = allFiles.FirstOrDefault(i => string.Equals(Path.GetFileName(i), encoderFilename, StringComparison.OrdinalIgnoreCase)); - var probe = allFiles.FirstOrDefault(i => string.Equals(Path.GetFileName(i), probeFilename, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrWhiteSpace(encoder) && - !string.IsNullOrWhiteSpace(probe)) - { - return new FFMpegInfo - { - EncoderPath = encoder, - ProbePath = probe, - Version = Path.GetFileName(Path.GetDirectoryName(probe)) - }; - } - } - - return null; - } - } -} diff --git a/Emby.Server.Implementations/HttpClientManager/HttpClientInfo.cs b/Emby.Server.Implementations/HttpClientManager/HttpClientInfo.cs deleted file mode 100644 index f747b01b9..000000000 --- a/Emby.Server.Implementations/HttpClientManager/HttpClientInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Net.Http; - -namespace Emby.Server.Implementations.HttpClientManager -{ - /// <summary> - /// Class HttpClientInfo - /// </summary> - public class HttpClientInfo - { - /// <summary> - /// Gets or sets the last timeout. - /// </summary> - /// <value>The last timeout.</value> - public DateTime LastTimeout { get; set; } - public HttpClient HttpClient { get; set; } - } -} diff --git a/Emby.Server.Implementations/HttpClientManager/HttpClientManager.cs b/Emby.Server.Implementations/HttpClientManager/HttpClientManager.cs deleted file mode 100644 index 2e0728136..000000000 --- a/Emby.Server.Implementations/HttpClientManager/HttpClientManager.cs +++ /dev/null @@ -1,800 +0,0 @@ -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.Cache; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.HttpClientManager -{ - /// <summary> - /// Class HttpClientManager - /// </summary> - public class HttpClientManager : IHttpClient - { - /// <summary> - /// When one request to a host times out, we'll ban all other requests for this period of time, to prevent scans from stalling - /// </summary> - private const int TimeoutSeconds = 30; - - /// <summary> - /// The _logger - /// </summary> - private readonly ILogger _logger; - - /// <summary> - /// The _app paths - /// </summary> - private readonly IApplicationPaths _appPaths; - - private readonly IFileSystem _fileSystem; - private readonly Func<string> _defaultUserAgentFn; - - /// <summary> - /// Initializes a new instance of the <see cref="HttpClientManager" /> class. - /// </summary> - public HttpClientManager( - IApplicationPaths appPaths, - ILoggerFactory loggerFactory, - IFileSystem fileSystem, - Func<string> defaultUserAgentFn) - { - if (appPaths == null) - { - throw new ArgumentNullException(nameof(appPaths)); - } - if (loggerFactory == null) - { - throw new ArgumentNullException(nameof(loggerFactory)); - } - - _logger = loggerFactory.CreateLogger("HttpClient"); - _fileSystem = fileSystem; - _appPaths = appPaths; - _defaultUserAgentFn = defaultUserAgentFn; - - // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c - ServicePointManager.Expect100Continue = false; - } - - /// <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, HttpClientInfo> _httpClients = new ConcurrentDictionary<string, HttpClientInfo>(); - - /// <summary> - /// Gets - /// </summary> - /// <param name="host">The host.</param> - /// <param name="enableHttpCompression">if set to <c>true</c> [enable HTTP compression].</param> - /// <returns>HttpClient.</returns> - /// <exception cref="ArgumentNullException">host</exception> - private HttpClientInfo GetHttpClient(string host, bool enableHttpCompression) - { - if (string.IsNullOrEmpty(host)) - { - throw new ArgumentNullException(nameof(host)); - } - - var key = host + enableHttpCompression; - - if (!_httpClients.TryGetValue(key, out var client)) - { - client = new HttpClientInfo(); - - _httpClients.TryAdd(key, client); - } - - return client; - } - - private WebRequest GetRequest(HttpRequestOptions options, string method) - { - string url = options.Url; - - var uriAddress = new Uri(url); - string userInfo = uriAddress.UserInfo; - if (!string.IsNullOrWhiteSpace(userInfo)) - { - _logger.LogInformation("Found userInfo in url: {0} ... url: {1}", userInfo, url); - url = url.Replace(userInfo + "@", string.Empty); - } - - var request = WebRequest.Create(url); - - if (request is HttpWebRequest httpWebRequest) - { - AddRequestHeaders(httpWebRequest, options); - - if (options.EnableHttpCompression) - { - httpWebRequest.AutomaticDecompression = DecompressionMethods.Deflate; - if (options.DecompressionMethod.HasValue - && options.DecompressionMethod.Value == CompressionMethod.Gzip) - { - httpWebRequest.AutomaticDecompression = DecompressionMethods.GZip; - } - } - else - { - httpWebRequest.AutomaticDecompression = DecompressionMethods.None; - } - - httpWebRequest.KeepAlive = options.EnableKeepAlive; - - if (!string.IsNullOrEmpty(options.Host)) - { - httpWebRequest.Host = options.Host; - } - - if (!string.IsNullOrEmpty(options.Referer)) - { - httpWebRequest.Referer = options.Referer; - } - } - - request.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache); - - request.Method = method; - request.Timeout = options.TimeoutMs; - - if (!string.IsNullOrWhiteSpace(userInfo)) - { - var parts = userInfo.Split(':'); - if (parts.Length == 2) - { - request.Credentials = GetCredential(url, parts[0], parts[1]); - // TODO: .net core ?? - request.PreAuthenticate = true; - } - } - - return request; - } - - private static CredentialCache GetCredential(string url, string username, string password) - { - //ServicePointManager.SecurityProtocol = SecurityProtocolType.Ssl3; - var credentialCache = new CredentialCache(); - credentialCache.Add(new Uri(url), "Basic", new NetworkCredential(username, password)); - return credentialCache; - } - - private void AddRequestHeaders(HttpWebRequest request, HttpRequestOptions options) - { - var hasUserAgent = false; - - foreach (var header in options.RequestHeaders) - { - if (string.Equals(header.Key, "Accept", StringComparison.OrdinalIgnoreCase)) - { - request.Accept = header.Value; - } - else if (string.Equals(header.Key, "User-Agent", StringComparison.OrdinalIgnoreCase)) - { - SetUserAgent(request, header.Value); - hasUserAgent = true; - } - else - { - request.Headers.Set(header.Key, header.Value); - } - } - - if (!hasUserAgent && options.EnableDefaultUserAgent) - { - SetUserAgent(request, _defaultUserAgentFn()); - } - } - - private static void SetUserAgent(HttpWebRequest request, string userAgent) - { - request.UserAgent = userAgent; - } - - /// <summary> - /// Gets the response internal. - /// </summary> - /// <param name="options">The options.</param> - /// <returns>Task{HttpResponseInfo}.</returns> - public Task<HttpResponseInfo> GetResponse(HttpRequestOptions options) - { - return SendAsync(options, "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> - /// <exception cref="HttpException"> - /// </exception> - public async Task<HttpResponseInfo> SendAsync(HttpRequestOptions options, string httpMethod) - { - if (options.CacheMode == CacheMode.None) - { - return await SendAsyncInternal(options, httpMethod).ConfigureAwait(false); - } - - var url = options.Url; - var urlHash = url.ToLowerInvariant().GetMD5().ToString("N"); - - 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 = _fileSystem.GetFileStream(responseCachePath, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.Read, 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 = _fileSystem.GetFileStream(responseCachePath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.None, true)) - { - await response.Content.CopyToAsync(fileStream).ConfigureAwait(false); - - response.Content.Position = 0; - } - } - - private async Task<HttpResponseInfo> SendAsyncInternal(HttpRequestOptions options, string httpMethod) - { - ValidateParams(options); - - options.CancellationToken.ThrowIfCancellationRequested(); - - var client = GetHttpClient(GetHostFromUrl(options.Url), options.EnableHttpCompression); - - if ((DateTime.UtcNow - client.LastTimeout).TotalSeconds < TimeoutSeconds) - { - throw new HttpException(string.Format("Cancelling connection to {0} due to a previous timeout.", options.Url)) - { - IsTimedOut = true - }; - } - - var httpWebRequest = GetRequest(options, httpMethod); - - if (options.RequestContentBytes != null || - !string.IsNullOrEmpty(options.RequestContent) || - string.Equals(httpMethod, "post", StringComparison.OrdinalIgnoreCase)) - { - try - { - var bytes = options.RequestContentBytes ?? Encoding.UTF8.GetBytes(options.RequestContent ?? string.Empty); - - var contentType = options.RequestContentType ?? "application/x-www-form-urlencoded"; - - if (options.AppendCharsetToMimeType) - { - contentType = contentType.TrimEnd(';') + "; charset=\"utf-8\""; - } - - httpWebRequest.ContentType = contentType; - httpWebRequest.ContentLength = bytes.Length; - (await httpWebRequest.GetRequestStreamAsync().ConfigureAwait(false)).Write(bytes, 0, bytes.Length); - } - catch (Exception ex) - { - throw new HttpException(ex.Message) { IsTimedOut = true }; - } - } - - if (options.ResourcePool != null) - { - await options.ResourcePool.WaitAsync(options.CancellationToken).ConfigureAwait(false); - } - - if ((DateTime.UtcNow - client.LastTimeout).TotalSeconds < TimeoutSeconds) - { - options.ResourcePool?.Release(); - - throw new HttpException($"Connection to {options.Url} timed out") { IsTimedOut = true }; - } - - if (options.LogRequest) - { - if (options.LogRequestAsDebug) - { - _logger.LogDebug("HttpClientManager {0}: {1}", httpMethod.ToUpper(CultureInfo.CurrentCulture), options.Url); - } - else - { - _logger.LogInformation("HttpClientManager {0}: {1}", httpMethod.ToUpper(CultureInfo.CurrentCulture), options.Url); - } - } - - try - { - options.CancellationToken.ThrowIfCancellationRequested(); - - if (!options.BufferContent) - { - var response = await GetResponseAsync(httpWebRequest, TimeSpan.FromMilliseconds(options.TimeoutMs)).ConfigureAwait(false); - - var httpResponse = (HttpWebResponse)response; - - EnsureSuccessStatusCode(client, httpResponse, options); - - options.CancellationToken.ThrowIfCancellationRequested(); - - return GetResponseInfo(httpResponse, httpResponse.GetResponseStream(), GetContentLength(httpResponse), httpResponse); - } - - using (var response = await GetResponseAsync(httpWebRequest, TimeSpan.FromMilliseconds(options.TimeoutMs)).ConfigureAwait(false)) - { - var httpResponse = (HttpWebResponse)response; - - EnsureSuccessStatusCode(client, httpResponse, options); - - options.CancellationToken.ThrowIfCancellationRequested(); - - using (var stream = httpResponse.GetResponseStream()) - { - var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream).ConfigureAwait(false); - - memoryStream.Position = 0; - - return GetResponseInfo(httpResponse, memoryStream, memoryStream.Length, null); - } - } - } - catch (OperationCanceledException ex) - { - throw GetCancellationException(options, client, options.CancellationToken, ex); - } - catch (Exception ex) - { - throw GetException(ex, options, client); - } - finally - { - options.ResourcePool?.Release(); - } - } - - private HttpResponseInfo GetResponseInfo(HttpWebResponse httpResponse, Stream content, long? contentLength, IDisposable disposable) - { - var responseInfo = new HttpResponseInfo(disposable) - { - Content = content, - StatusCode = httpResponse.StatusCode, - ContentType = httpResponse.ContentType, - ContentLength = contentLength, - ResponseUrl = httpResponse.ResponseUri.ToString() - }; - - if (httpResponse.Headers != null) - { - SetHeaders(httpResponse.Headers, responseInfo); - } - - return responseInfo; - } - - private HttpResponseInfo GetResponseInfo(HttpWebResponse httpResponse, string tempFile, long? contentLength) - { - var responseInfo = new HttpResponseInfo - { - TempFilePath = tempFile, - StatusCode = httpResponse.StatusCode, - ContentType = httpResponse.ContentType, - ContentLength = contentLength - }; - - if (httpResponse.Headers != null) - { - SetHeaders(httpResponse.Headers, responseInfo); - } - - return responseInfo; - } - - private static void SetHeaders(WebHeaderCollection headers, HttpResponseInfo responseInfo) - { - foreach (var key in headers.AllKeys) - { - responseInfo.Headers[key] = headers[key]; - } - } - - public Task<HttpResponseInfo> Post(HttpRequestOptions options) - { - return SendAsync(options, "POST"); - } - - /// <summary> - /// Performs a POST request - /// </summary> - /// <param name="options">The options.</param> - /// <param name="postData">Params to add to the POST data.</param> - /// <returns>stream on success, null on failure</returns> - public async Task<Stream> Post(HttpRequestOptions options, Dictionary<string, string> postData) - { - options.SetPostData(postData); - - var response = await Post(options).ConfigureAwait(false); - - return response.Content; - } - - /// <summary> - /// Downloads the contents of a given url into a temporary location - /// </summary> - /// <param name="options">The options.</param> - /// <returns>Task{System.String}.</returns> - public async Task<string> GetTempFile(HttpRequestOptions options) - { - var response = await GetTempFileResponse(options).ConfigureAwait(false); - - return response.TempFilePath; - } - - public async Task<HttpResponseInfo> GetTempFileResponse(HttpRequestOptions options) - { - ValidateParams(options); - - Directory.CreateDirectory(_appPaths.TempDirectory); - - var tempFile = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + ".tmp"); - - if (options.Progress == null) - { - throw new ArgumentException("Options did not have a Progress value.", nameof(options)); - } - - options.CancellationToken.ThrowIfCancellationRequested(); - - var httpWebRequest = GetRequest(options, "GET"); - - if (options.ResourcePool != null) - { - await options.ResourcePool.WaitAsync(options.CancellationToken).ConfigureAwait(false); - } - - options.Progress.Report(0); - - if (options.LogRequest) - { - if (options.LogRequestAsDebug) - { - _logger.LogDebug("HttpClientManager.GetTempFileResponse url: {0}", options.Url); - } - else - { - _logger.LogInformation("HttpClientManager.GetTempFileResponse url: {0}", options.Url); - } - } - - var client = GetHttpClient(GetHostFromUrl(options.Url), options.EnableHttpCompression); - - try - { - options.CancellationToken.ThrowIfCancellationRequested(); - - using (var response = await httpWebRequest.GetResponseAsync().ConfigureAwait(false)) - { - var httpResponse = (HttpWebResponse)response; - - EnsureSuccessStatusCode(client, httpResponse, options); - - options.CancellationToken.ThrowIfCancellationRequested(); - - var contentLength = GetContentLength(httpResponse); - - using (var stream = httpResponse.GetResponseStream()) - using (var fs = _fileSystem.GetFileStream(tempFile, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, true)) - { - await stream.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, options.CancellationToken).ConfigureAwait(false); - } - - options.Progress.Report(100); - - return GetResponseInfo(httpResponse, tempFile, contentLength); - } - } - catch (Exception ex) - { - DeleteTempFile(tempFile); - throw GetException(ex, options, client); - } - finally - { - options.ResourcePool?.Release(); - } - } - - private static long? GetContentLength(HttpWebResponse response) - { - var length = response.ContentLength; - - if (length == 0) - { - return null; - } - - return length; - } - - protected static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - - private Exception GetException(Exception ex, HttpRequestOptions options, HttpClientInfo client) - { - if (ex is HttpException) - { - return ex; - } - - var webException = ex as WebException - ?? ex.InnerException as WebException; - - if (webException != null) - { - if (options.LogErrors) - { - _logger.LogError(webException, "Error {status} getting response from {url}", webException.Status, options.Url); - } - - var exception = new HttpException(webException.Message, webException); - - using (var response = webException.Response as HttpWebResponse) - { - if (response != null) - { - exception.StatusCode = response.StatusCode; - - if ((int)response.StatusCode == 429) - { - client.LastTimeout = DateTime.UtcNow; - } - } - } - - if (!exception.StatusCode.HasValue) - { - if (webException.Status == WebExceptionStatus.NameResolutionFailure || - webException.Status == WebExceptionStatus.ConnectFailure) - { - exception.IsTimedOut = true; - } - } - - return exception; - } - - var operationCanceledException = ex as OperationCanceledException - ?? ex.InnerException as OperationCanceledException; - - if (operationCanceledException != null) - { - return GetCancellationException(options, client, options.CancellationToken, operationCanceledException); - } - - if (options.LogErrors) - { - _logger.LogError(ex, "Error getting response from {url}", options.Url); - } - - return ex; - } - - private void DeleteTempFile(string file) - { - try - { - _fileSystem.DeleteFile(file); - } - catch (IOException) - { - // Might not have been created at all. No need to worry. - } - } - - 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; - } - - /// <summary> - /// Throws the cancellation exception. - /// </summary> - /// <param name="options">The options.</param> - /// <param name="client">The client.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="exception">The exception.</param> - /// <returns>Exception.</returns> - private Exception GetCancellationException(HttpRequestOptions options, HttpClientInfo client, CancellationToken cancellationToken, OperationCanceledException exception) - { - // If the HttpClient's timeout is reached, it will cancel the Task internally - if (!cancellationToken.IsCancellationRequested) - { - var msg = string.Format("Connection to {0} timed out", options.Url); - - if (options.LogErrors) - { - _logger.LogError(msg); - } - - client.LastTimeout = DateTime.UtcNow; - - // Throw an HttpException so that the caller doesn't think it was cancelled by user code - return new HttpException(msg, exception) - { - IsTimedOut = true - }; - } - - return exception; - } - - private void EnsureSuccessStatusCode(HttpClientInfo client, HttpWebResponse response, HttpRequestOptions options) - { - var statusCode = response.StatusCode; - - var isSuccessful = statusCode >= HttpStatusCode.OK && statusCode <= (HttpStatusCode)299; - - if (isSuccessful) - { - return; - } - - if (options.LogErrorResponseBody) - { - try - { - using (var stream = response.GetResponseStream()) - { - if (stream != null) - { - using (var reader = new StreamReader(stream)) - { - var msg = reader.ReadToEnd(); - - _logger.LogError(msg); - } - } - } - } - catch - { - - } - } - - throw new HttpException(response.StatusDescription) - { - StatusCode = response.StatusCode - }; - } - - private static Task<WebResponse> GetResponseAsync(WebRequest request, TimeSpan timeout) - { - var taskCompletion = new TaskCompletionSource<WebResponse>(); - - var asyncTask = Task.Factory.FromAsync(request.BeginGetResponse, request.EndGetResponse, null); - - ThreadPool.RegisterWaitForSingleObject((asyncTask as IAsyncResult).AsyncWaitHandle, TimeoutCallback, request, timeout, true); - var callback = new TaskCallback { taskCompletion = taskCompletion }; - asyncTask.ContinueWith(callback.OnSuccess, TaskContinuationOptions.NotOnFaulted); - - // Handle errors - asyncTask.ContinueWith(callback.OnError, TaskContinuationOptions.OnlyOnFaulted); - - return taskCompletion.Task; - } - - private static void TimeoutCallback(object state, bool timedOut) - { - if (timedOut && state != null) - { - var request = (WebRequest)state; - request.Abort(); - } - } - - private class TaskCallback - { - public TaskCompletionSource<WebResponse> taskCompletion; - - public void OnSuccess(Task<WebResponse> task) - { - taskCompletion.TrySetResult(task.Result); - } - - public void OnError(Task<WebResponse> task) - { - if (task.Exception == null) - { - taskCompletion.TrySetException(Enumerable.Empty<Exception>()); - } - else - { - taskCompletion.TrySetException(task.Exception); - } - } - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/FileWriter.cs b/Emby.Server.Implementations/HttpServer/FileWriter.cs deleted file mode 100644 index 7aedba9b3..000000000 --- a/Emby.Server.Implementations/HttpServer/FileWriter.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.HttpServer -{ - public class FileWriter : IHttpResult - { - private ILogger Logger { 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; } - public long TotalContentLength { get; set; } - - public Action OnComplete { get; set; } - public Action OnError { get; set; } - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - public List<Cookie> Cookies { get; private set; } - - public FileShareMode FileShare { get; set; } - - /// <summary> - /// The _options - /// </summary> - private readonly IDictionary<string, string> _options = new Dictionary<string, string>(); - /// <summary> - /// Gets the options. - /// </summary> - /// <value>The options.</value> - public IDictionary<string, string> Headers => _options; - - public string Path { get; set; } - - public FileWriter(string path, string contentType, string rangeHeader, ILogger logger, IFileSystem fileSystem) - { - if (string.IsNullOrEmpty(contentType)) - { - throw new ArgumentNullException(nameof(contentType)); - } - - Path = path; - Logger = logger; - RangeHeader = rangeHeader; - - Headers["Content-Type"] = contentType; - - TotalContentLength = fileSystem.GetFileInfo(path).Length; - Headers["Accept-Ranges"] = "bytes"; - - if (string.IsNullOrWhiteSpace(rangeHeader)) - { - Headers["Content-Length"] = TotalContentLength.ToString(UsCulture); - StatusCode = HttpStatusCode.OK; - } - else - { - StatusCode = HttpStatusCode.PartialContent; - SetRangeValues(); - } - - FileShare = FileShareMode.Read; - Cookies = new List<Cookie>(); - } - - /// <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(UsCulture); - Headers["Content-Length"] = lengthString; - var rangeString = string.Format("bytes {0}-{1}/{2}", RangeStart, RangeEnd, TotalContentLength); - Headers["Content-Range"] = rangeString; - - Logger.LogInformation("Setting range response values for {0}. RangeRequest: {1} Content-Length: {2}, Content-Range: {3}", Path, RangeHeader, lengthString, rangeString); - } - - /// <summary> - /// The _requested ranges - /// </summary> - private List<KeyValuePair<long, long?>> _requestedRanges; - /// <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; - } - } - - private string[] SkipLogExtensions = new string[] - { - ".js", - ".html", - ".css" - }; - - public async Task WriteToAsync(IResponse response, CancellationToken cancellationToken) - { - try - { - // Headers only - if (IsHeadRequest) - { - return; - } - - var path = Path; - - 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); - } - - //var count = FileShare == FileShareMode.ReadWrite ? TotalContentLength : 0; - - await response.TransmitFile(path, 0, 0, FileShare, cancellationToken).ConfigureAwait(false); - return; - } - - await response.TransmitFile(path, RangeStart, RangeLength, FileShare, cancellationToken).ConfigureAwait(false); - } - finally - { - if (OnComplete != null) - { - OnComplete(); - } - } - } - - 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; - } - - public string StatusDescription { get; set; } - - } -} diff --git a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs deleted file mode 100644 index d78891ac7..000000000 --- a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs +++ /dev/null @@ -1,901 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Sockets; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.Net; -using Emby.Server.Implementations.Services; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Extensions; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using ServiceStack.Text.Jsv; - -namespace Emby.Server.Implementations.HttpServer -{ - public class HttpListenerHost : IHttpServer, IDisposable - { - private string DefaultRedirectPath { get; set; } - - private readonly ILogger _logger; - public string[] UrlPrefixes { get; private set; } - - private IHttpListener _listener; - - public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected; - - 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; - - public Action<IRequest, IResponse, object>[] ResponseFilters { get; set; } - - private readonly Dictionary<Type, Type> ServiceOperationsMap = new Dictionary<Type, Type>(); - public static HttpListenerHost Instance { get; protected set; } - - private IWebSocketListener[] _webSocketListeners = Array.Empty<IWebSocketListener>(); - private readonly List<IWebSocketConnection> _webSocketConnections = new List<IWebSocketConnection>(); - - public HttpListenerHost( - IServerApplicationHost applicationHost, - ILoggerFactory loggerFactory, - IServerConfigurationManager config, - IConfiguration configuration, - INetworkManager networkManager, - IJsonSerializer jsonSerializer, - IXmlSerializer xmlSerializer) - { - _appHost = applicationHost; - _logger = loggerFactory.CreateLogger("HttpServer"); - _config = config; - DefaultRedirectPath = configuration["HttpListenerHost:DefaultRedirectPath"]; - _networkManager = networkManager; - _jsonSerializer = jsonSerializer; - _xmlSerializer = xmlSerializer; - - _funcParseFn = t => s => JsvReader.GetParseFn(t)(s); - - Instance = this; - ResponseFilters = Array.Empty<Action<IRequest, IResponse, object>>(); - } - - public string GlobalResponse { get; set; } - - protected ILogger Logger => _logger; - - public object CreateInstance(Type type) - { - return _appHost.CreateInstance(type); - } - - /// <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, IResponse 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.GetTypeInfo().GetCustomAttributes(true).OfType<IHasRequestFilter>().ToList(); - - var serviceType = GetServiceTypeByRequest(requestDtoType); - if (serviceType != null) - { - attributes.AddRange(serviceType.GetTypeInfo().GetCustomAttributes(true).OfType<IHasRequestFilter>()); - } - - attributes.Sort((x, y) => x.Priority - y.Priority); - - return attributes; - } - - private void OnWebSocketConnected(WebSocketConnectEventArgs e) - { - if (_disposed) - { - return; - } - - var connection = new WebSocketConnection(e.WebSocket, e.Endpoint, _jsonSerializer, _logger) - { - OnReceive = ProcessWebSocketMessageReceived, - Url = e.Url, - QueryString = e.QueryString ?? new QueryParamCollection() - }; - - connection.Closed += Connection_Closed; - - lock (_webSocketConnections) - { - _webSocketConnections.Add(connection); - } - - WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection)); - } - - private void Connection_Closed(object sender, EventArgs e) - { - lock (_webSocketConnections) - { - _webSocketConnections.Remove((IWebSocketConnection)sender); - } - } - - 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 != null && inners.Count > 0) - { - return GetActualException(inners[0]); - } - } - } - - return ex; - } - - private int GetStatusCode(Exception ex) - { - switch (ex) - { - case ArgumentException _: return 400; - case SecurityException _: return 401; - case DirectoryNotFoundException _: - case FileNotFoundException _: - case ResourceNotFoundException _: return 404; - case RemoteServiceUnavailableException _: return 502; - default: return 500; - } - } - - private async Task ErrorHandler(Exception ex, IRequest httpReq, bool logExceptionStackTrace, bool logExceptionMessage) - { - try - { - ex = GetActualException(ex); - - if (logExceptionStackTrace) - { - _logger.LogError(ex, "Error processing request"); - } - else if (logExceptionMessage) - { - _logger.LogError(ex.Message); - } - - var httpRes = httpReq.Response; - - if (httpRes.IsClosed) - { - return; - } - - var statusCode = GetStatusCode(ex); - httpRes.StatusCode = statusCode; - - httpRes.ContentType = "text/html"; - await Write(httpRes, NormalizeExceptionMessage(ex.Message)).ConfigureAwait(false); - } - catch (Exception errorEx) - { - _logger.LogError(errorEx, "Error this.ProcessRequest(context)(Exception while writing error to the response)"); - } - } - - private string NormalizeExceptionMessage(string msg) - { - if (msg == null) - { - return string.Empty; - } - - // Strip any information we don't want to reveal - - msg = msg.Replace(_config.ApplicationPaths.ProgramSystemPath, string.Empty, StringComparison.OrdinalIgnoreCase); - msg = msg.Replace(_config.ApplicationPaths.ProgramDataPath, string.Empty, StringComparison.OrdinalIgnoreCase); - - return msg; - } - - /// <summary> - /// Shut down the Web Service - /// </summary> - public void Stop() - { - List<IWebSocketConnection> connections; - - lock (_webSocketConnections) - { - connections = _webSocketConnections.ToList(); - _webSocketConnections.Clear(); - } - - foreach (var connection in connections) - { - try - { - connection.Dispose(); - } - catch - { - - } - } - - if (_listener != null) - { - _logger.LogInformation("Stopping HttpListener..."); - var task = _listener.Stop(); - Task.WaitAll(task); - _logger.LogInformation("HttpListener stopped"); - } - } - - private static readonly string[] _skipLogExtensions = - { - ".js", - ".css", - ".woff", - ".woff2", - ".ttf", - ".html" - }; - - private bool EnableLogging(string url, string localPath) - { - var extension = GetExtension(url); - - return ((string.IsNullOrEmpty(extension) || !_skipLogExtensions.Contains(extension)) - && (string.IsNullOrEmpty(localPath) || localPath.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) == -1)); - } - - private static string GetExtension(string url) - { - var parts = url.Split(new[] { '?' }, 2); - - return Path.GetExtension(parts[0]); - } - - 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 = MyHttpUtility.ParseQueryString(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 - ? string.Format("{0}?{1}", pagePathWithoutQueryString, newQueryString) - : pagePathWithoutQueryString; - } - - private static string GetUrlToLog(string url) - { - url = RemoveQueryStringByKey(url, "api_key"); - - return url; - } - - private static string NormalizeConfiguredLocalAddress(string address) - { - var index = address.Trim('/').IndexOf('/'); - - if (index != -1) - { - address = address.Substring(index + 1); - } - - return address.Trim('/'); - } - - private bool ValidateHost(string host) - { - var hosts = _config - .Configuration - .LocalNetworkAddresses - .Select(NormalizeConfiguredLocalAddress) - .ToList(); - - if (hosts.Count == 0) - { - return true; - } - - host = 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; - } - - private bool ValidateSsl(string remoteIp, string urlString) - { - if (_config.Configuration.RequireHttps && _appHost.EnableHttps && !_config.Configuration.IsBehindProxy) - { - if (urlString.IndexOf("https://", StringComparison.OrdinalIgnoreCase) == -1) - { - // 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; - } - - /// <summary> - /// Overridable method that can be used to implement a custom hnandler - /// </summary> - protected async Task RequestHandler(IHttpRequest httpReq, string urlString, string host, string localPath, CancellationToken cancellationToken) - { - var date = DateTime.Now; - var httpRes = httpReq.Response; - bool enableLog = false; - bool logHeaders = false; - string urlToLog = null; - string remoteIp = httpReq.RemoteIp; - - try - { - if (_disposed) - { - httpRes.StatusCode = 503; - httpRes.ContentType = "text/plain"; - await Write(httpRes, "Server shutting down").ConfigureAwait(false); - return; - } - - if (!ValidateHost(host)) - { - httpRes.StatusCode = 400; - httpRes.ContentType = "text/plain"; - await Write(httpRes, "Invalid host").ConfigureAwait(false); - return; - } - - if (!ValidateRequest(remoteIp, httpReq.IsLocal)) - { - httpRes.StatusCode = 403; - httpRes.ContentType = "text/plain"; - await Write(httpRes, "Forbidden").ConfigureAwait(false); - return; - } - - if (!ValidateSsl(httpReq.RemoteIp, urlString)) - { - RedirectToSecureUrl(httpReq, httpRes, urlString); - return; - } - - if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase)) - { - httpRes.StatusCode = 200; - httpRes.AddHeader("Access-Control-Allow-Origin", "*"); - httpRes.AddHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); - httpRes.AddHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Range, X-MediaBrowser-Token, X-Emby-Authorization"); - httpRes.ContentType = "text/plain"; - await Write(httpRes, string.Empty).ConfigureAwait(false); - return; - } - - var operationName = httpReq.OperationName; - - enableLog = EnableLogging(urlString, localPath); - urlToLog = urlString; - logHeaders = enableLog && urlToLog.IndexOf("/videos/", StringComparison.OrdinalIgnoreCase) != -1; - - if (enableLog) - { - urlToLog = GetUrlToLog(urlString); - - LoggerUtils.LogRequest(_logger, urlToLog, httpReq.HttpMethod, httpReq.UserAgent, logHeaders ? httpReq.Headers : null); - } - - if (string.Equals(localPath, "/emby/", StringComparison.OrdinalIgnoreCase) || - string.Equals(localPath, "/mediabrowser/", StringComparison.OrdinalIgnoreCase)) - { - RedirectToUrl(httpRes, DefaultRedirectPath); - return; - } - if (string.Equals(localPath, "/emby", StringComparison.OrdinalIgnoreCase) || - string.Equals(localPath, "/mediabrowser", StringComparison.OrdinalIgnoreCase)) - { - RedirectToUrl(httpRes, "emby/" + DefaultRedirectPath); - return; - } - - if (localPath.IndexOf("mediabrowser/web", StringComparison.OrdinalIgnoreCase) != -1) - { - httpRes.StatusCode = 200; - httpRes.ContentType = "text/html"; - var newUrl = urlString.Replace("mediabrowser", "emby", StringComparison.OrdinalIgnoreCase) - .Replace("/dashboard/", "/web/", StringComparison.OrdinalIgnoreCase); - - if (!string.Equals(newUrl, urlString, StringComparison.OrdinalIgnoreCase)) - { - await Write(httpRes, - "<!doctype html><html><head><title>Emby</title></head><body>Please update your Emby bookmark to <a href=\"" + - newUrl + "\">" + newUrl + "</a></body></html>").ConfigureAwait(false); - return; - } - } - - if (localPath.IndexOf("dashboard/", StringComparison.OrdinalIgnoreCase) != -1 && - localPath.IndexOf("web/dashboard", StringComparison.OrdinalIgnoreCase) == -1) - { - httpRes.StatusCode = 200; - httpRes.ContentType = "text/html"; - var newUrl = urlString.Replace("mediabrowser", "emby", StringComparison.OrdinalIgnoreCase) - .Replace("/dashboard/", "/web/", StringComparison.OrdinalIgnoreCase); - - if (!string.Equals(newUrl, urlString, StringComparison.OrdinalIgnoreCase)) - { - await Write(httpRes, - "<!doctype html><html><head><title>Emby</title></head><body>Please update your Emby bookmark to <a href=\"" + - newUrl + "\">" + newUrl + "</a></body></html>").ConfigureAwait(false); - return; - } - } - - if (string.Equals(localPath, "/web", StringComparison.OrdinalIgnoreCase)) - { - RedirectToUrl(httpRes, DefaultRedirectPath); - return; - } - if (string.Equals(localPath, "/web/", StringComparison.OrdinalIgnoreCase)) - { - RedirectToUrl(httpRes, "../" + DefaultRedirectPath); - return; - } - if (string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase)) - { - RedirectToUrl(httpRes, DefaultRedirectPath); - return; - } - if (string.IsNullOrEmpty(localPath)) - { - RedirectToUrl(httpRes, "/" + DefaultRedirectPath); - return; - } - - if (!string.Equals(httpReq.QueryString["r"], "0", StringComparison.OrdinalIgnoreCase)) - { - if (localPath.EndsWith("web/dashboard.html", StringComparison.OrdinalIgnoreCase)) - { - RedirectToUrl(httpRes, "index.html#!/dashboard.html"); - } - - if (localPath.EndsWith("web/home.html", StringComparison.OrdinalIgnoreCase)) - { - RedirectToUrl(httpRes, "index.html"); - } - } - - 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 Write(httpRes, GlobalResponse).ConfigureAwait(false); - return; - } - } - - var handler = GetServiceHandler(httpReq); - - if (handler != null) - { - await handler.ProcessRequestAsync(this, httpReq, httpRes, Logger, operationName, cancellationToken).ConfigureAwait(false); - } - else - { - await ErrorHandler(new FileNotFoundException(), httpReq, false, false).ConfigureAwait(false); - } - } - catch (OperationCanceledException ex) - { - await ErrorHandler(ex, httpReq, false, false).ConfigureAwait(false); - } - - catch (IOException ex) - { - await ErrorHandler(ex, httpReq, false, false).ConfigureAwait(false); - } - - catch (SocketException ex) - { - await ErrorHandler(ex, httpReq, false, false).ConfigureAwait(false); - } - - catch (SecurityException ex) - { - await ErrorHandler(ex, httpReq, false, true).ConfigureAwait(false); - } - - catch (Exception ex) - { - var logException = !string.Equals(ex.GetType().Name, "SocketException", StringComparison.OrdinalIgnoreCase); - - await ErrorHandler(ex, httpReq, logException, false).ConfigureAwait(false); - } - finally - { - httpRes.Close(); - - if (enableLog) - { - var statusCode = httpRes.StatusCode; - - var duration = DateTime.Now - date; - - LoggerUtils.LogResponse(_logger, statusCode, urlToLog, remoteIp, duration, logHeaders ? httpRes.Headers : null); - } - } - } - - // Entry point for HttpListener - public ServiceHandler GetServiceHandler(IHttpRequest httpReq) - { - var pathInfo = httpReq.PathInfo; - - var pathParts = pathInfo.TrimStart('/').Split('/'); - if (pathParts.Length == 0) - { - _logger.LogError("Path parts empty for PathInfo: {pathInfo}, Url: {RawUrl}", pathInfo, httpReq.RawUrl); - return null; - } - - var restPath = ServiceHandler.FindMatchingRestPath(httpReq.HttpMethod, pathInfo, out string contentType); - - if (restPath != null) - { - return new ServiceHandler - { - RestPath = restPath, - ResponseContentType = contentType - }; - } - - _logger.LogError("Could not find handler for {PathInfo}", pathInfo); - return null; - } - - private static Task Write(IResponse response, string text) - { - var bOutput = Encoding.UTF8.GetBytes(text); - response.SetContentLength(bOutput.Length); - - return response.OutputStream.WriteAsync(bOutput, 0, bOutput.Length); - } - - private void RedirectToSecureUrl(IHttpRequest httpReq, IResponse 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(); - - RedirectToUrl(httpRes, url); - } - else - { - var httpsUrl = url - .Replace("http://", "https://", StringComparison.OrdinalIgnoreCase) - .Replace(":" + _config.Configuration.PublicPort.ToString(CultureInfo.InvariantCulture), ":" + _config.Configuration.PublicHttpsPort.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); - - RedirectToUrl(httpRes, url); - } - } - - public static void RedirectToUrl(IResponse httpRes, string url) - { - httpRes.StatusCode = 302; - httpRes.AddHeader("Location", url); - } - - public ServiceController ServiceController { get; private set; } - - /// <summary> - /// Adds the rest handlers. - /// </summary> - /// <param name="services">The services.</param> - public void Init(IEnumerable<IService> services, IEnumerable<IWebSocketListener> listeners) - { - _webSocketListeners = listeners.ToArray(); - - ServiceController = new ServiceController(); - - _logger.LogInformation("Calling ServiceStack AppHost.Init"); - - var types = services.Select(r => r.GetType()).ToArray(); - - ServiceController.Init(this, types); - - ResponseFilters = new Action<IRequest, IResponse, object>[] - { - new ResponseFilter(_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(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 - }); - - // needed because apps add /emby, and some users also add /emby, thereby double prefixing - routes.Add(new RouteAttribute(DoubleNormalizeEmbyRoutePath(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); - } - - //TODO Add Jellyfin Route Path Normalizer - - private static string NormalizeEmbyRoutePath(string path) - { - if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase)) - { - return "/emby" + path; - } - - return "emby/" + path; - } - - private static string NormalizeMediaBrowserRoutePath(string path) - { - if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase)) - { - return "/mediabrowser" + path; - } - - return "mediabrowser/" + path; - } - - private static string DoubleNormalizeEmbyRoutePath(string path) - { - if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase)) - { - return "/emby/emby" + path; - } - - return "emby/emby/" + path; - } - - private bool _disposed; - private readonly object _disposeLock = new object(); - protected virtual void Dispose(bool disposing) - { - if (_disposed) return; - - lock (_disposeLock) - { - if (_disposed) return; - - _disposed = true; - - if (disposing) - { - Stop(); - } - } - } - - /// <summary> - /// Processes the web socket message received. - /// </summary> - /// <param name="result">The result.</param> - private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) - { - if (_disposed) - { - return Task.CompletedTask; - } - - _logger.LogDebug("Websocket message received: {0}", result.MessageType); - - var tasks = _webSocketListeners.Select(i => Task.Run(async () => - { - try - { - await i.ProcessMessage(result).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "{0} failed processing WebSocket message {1}", i.GetType().Name, result.MessageType ?? string.Empty); - } - })); - - return Task.WhenAll(tasks); - } - - public void Dispose() - { - Dispose(true); - } - - public void StartServer(string[] urlPrefixes, IHttpListener httpListener) - { - UrlPrefixes = urlPrefixes; - - _listener = httpListener; - - _listener.WebSocketConnected = OnWebSocketConnected; - _listener.ErrorHandler = ErrorHandler; - _listener.RequestHandler = RequestHandler; - - _listener.Start(UrlPrefixes); - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs b/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs deleted file mode 100644 index 070717d48..000000000 --- a/Emby.Server.Implementations/HttpServer/HttpResultFactory.cs +++ /dev/null @@ -1,722 +0,0 @@ -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.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using System.Xml; -using Emby.Server.Implementations.Services; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -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 - { - /// <summary> - /// The _logger - /// </summary> - private readonly ILogger _logger; - private readonly IFileSystem _fileSystem; - private readonly IJsonSerializer _jsonSerializer; - - private IBrotliCompressor _brotliCompressor; - - /// <summary> - /// Initializes a new instance of the <see cref="HttpResultFactory" /> class. - /// </summary> - public HttpResultFactory(ILoggerFactory loggerfactory, IFileSystem fileSystem, IJsonSerializer jsonSerializer, IBrotliCompressor brotliCompressor) - { - _fileSystem = fileSystem; - _jsonSerializer = jsonSerializer; - _brotliCompressor = brotliCompressor; - _logger = loggerfactory.CreateLogger("HttpResultFactory"); - } - - /// <summary> - /// Gets the result. - /// </summary> - /// <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["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("Expires", out string expires)) - { - responseHeaders["Expires"] = "-1"; - } - - 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("Expires", out string _)) - { - responseHeaders["Expires"] = "-1"; - } - - 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("Expires", out string _)) - { - responseHeaders["Expires"] = "-1"; - } - - 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["Expires"] = "-1"; - - 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["Accept-Encoding"]; - - if (acceptEncoding != null) - { - //if (_brotliCompressor != null && acceptEncoding.IndexOf("br", StringComparison.OrdinalIgnoreCase) != -1) - // return "br"; - - if (acceptEncoding.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1) - return "deflate"; - - if (acceptEncoding.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) != -1) - 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["Content-Encoding"] = requestedCompressionType; - - responseHeaders["Vary"] = "Accept-Encoding"; - - 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, "br", StringComparison.OrdinalIgnoreCase)) - { - return CompressBrotli(bytes); - } - - 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 byte[] CompressBrotli(byte[] bytes) - { - return _brotliCompressor.Compress(bytes); - } - - 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.Get("Cache-Control") ?? string.Empty).IndexOf("no-cache", StringComparison.OrdinalIgnoreCase) != -1; - AddCachingHeaders(responseHeaders, options.CacheDuration, noCache, options.DateLastModified); - - if (!noCache) - { - DateTime.TryParse(requestContext.Headers.Get("If-Modified-Since"), out var ifModifiedSinceHeader); - - 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, - FileShareMode fileShare = FileShareMode.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 ArgumentNullException(nameof(path)); - } - - if (fileShare != FileShareMode.Read && fileShare != FileShareMode.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, FileShareMode fileShare) - { - return _fileSystem.GetFileStream(path, FileOpenMode.Open, FileAccessMode.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 (!string.IsNullOrEmpty(requestContext.Headers.Get("If-Modified-Since"))) - { - // 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.Get("Range"); - - if (!isHeadRequest && !string.IsNullOrEmpty(options.Path)) - { - var hasHeaders = new FileWriter(options.Path, contentType, rangeHeader, _logger, _fileSystem) - { - 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, _logger) - { - OnComplete = options.OnComplete - }; - - AddResponseHeaders(hasHeaders, options.ResponseHeaders); - return hasHeaders; - } - else - { - if (totalContentLength.HasValue) - { - responseHeaders["Content-Length"] = totalContentLength.Value.ToString(UsCulture); - } - - 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> - /// The us culture - /// </summary> - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - - /// <summary> - /// Adds the caching responseHeaders. - /// </summary> - private void AddCachingHeaders(IDictionary<string, string> responseHeaders, TimeSpan? cacheDuration, - bool noCache, DateTime? lastModifiedDate) - { - if (noCache) - { - responseHeaders["Cache-Control"] = "no-cache, no-store, must-revalidate"; - responseHeaders["pragma"] = "no-cache, no-store, must-revalidate"; - return; - } - - if (cacheDuration.HasValue) - { - responseHeaders["Cache-Control"] = "public, max-age=" + cacheDuration.Value.TotalSeconds; - } - else - { - responseHeaders["Cache-Control"] = "public"; - } - - if (lastModifiedDate.HasValue) - { - responseHeaders["Last-Modified"] = lastModifiedDate.ToString(); - } - } - - /// <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["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; - } - } - } - - public interface IBrotliCompressor - { - byte[] Compress(byte[] content); - } -} diff --git a/Emby.Server.Implementations/HttpServer/IHttpListener.cs b/Emby.Server.Implementations/HttpServer/IHttpListener.cs deleted file mode 100644 index 835091361..000000000 --- a/Emby.Server.Implementations/HttpServer/IHttpListener.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.Net; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.HttpServer -{ - public interface IHttpListener : IDisposable - { - /// <summary> - /// Gets or sets the error handler. - /// </summary> - /// <value>The error handler.</value> - Func<Exception, IRequest, bool, bool, Task> ErrorHandler { get; set; } - - /// <summary> - /// Gets or sets the request handler. - /// </summary> - /// <value>The request handler.</value> - Func<IHttpRequest, string, string, string, CancellationToken, Task> RequestHandler { get; set; } - - /// <summary> - /// Gets or sets the web socket handler. - /// </summary> - /// <value>The web socket handler.</value> - Action<WebSocketConnectEventArgs> WebSocketConnected { get; set; } - - /// <summary> - /// Gets or sets the web socket connecting. - /// </summary> - /// <value>The web socket connecting.</value> - Action<WebSocketConnectingEventArgs> WebSocketConnecting { get; set; } - - /// <summary> - /// Starts this instance. - /// </summary> - /// <param name="urlPrefixes">The URL prefixes.</param> - void Start(IEnumerable<string> urlPrefixes); - - /// <summary> - /// Stops this instance. - /// </summary> - Task Stop(); - } -} diff --git a/Emby.Server.Implementations/HttpServer/LoggerUtils.cs b/Emby.Server.Implementations/HttpServer/LoggerUtils.cs deleted file mode 100644 index d22d9db26..000000000 --- a/Emby.Server.Implementations/HttpServer/LoggerUtils.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Globalization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.HttpServer -{ - public static class LoggerUtils - { - public static void LogRequest(ILogger logger, string url, string method, string userAgent, QueryParamCollection headers) - { - if (headers == null) - { - logger.LogInformation("{0} {1}. UserAgent: {2}", "HTTP " + method, url, userAgent ?? string.Empty); - } - else - { - var headerText = string.Empty; - var index = 0; - - foreach (var i in headers) - { - if (index > 0) - { - headerText += ", "; - } - - headerText += i.Name + "=" + i.Value; - - index++; - } - - logger.LogInformation("HTTP {0} {1}. {2}", method, url, headerText); - } - } - - /// <summary> - /// Logs the response. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="statusCode">The status code.</param> - /// <param name="url">The URL.</param> - /// <param name="endPoint">The end point.</param> - /// <param name="duration">The duration.</param> - public static void LogResponse(ILogger logger, int statusCode, string url, string endPoint, TimeSpan duration, QueryParamCollection headers) - { - var durationMs = duration.TotalMilliseconds; - var logSuffix = durationMs >= 1000 && durationMs < 60000 ? "ms (slow)" : "ms"; - - //var headerText = headers == null ? string.Empty : "Headers: " + string.Join(", ", headers.Where(i => i.Name.IndexOf("Access-", StringComparison.OrdinalIgnoreCase) == -1).Select(i => i.Name + "=" + i.Value).ToArray()); - var headerText = string.Empty; - logger.LogInformation("HTTP Response {0} to {1}. Time: {2}{3}. {4} {5}", statusCode, endPoint, Convert.ToInt32(durationMs).ToString(CultureInfo.InvariantCulture), logSuffix, url, headerText); - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs b/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs deleted file mode 100644 index 891a76ec2..000000000 --- a/Emby.Server.Implementations/HttpServer/RangeRequestWriter.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System; -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.Extensions.Logging; - -namespace Emby.Server.Implementations.HttpServer -{ - public class RangeRequestWriter : IAsyncStreamWriter, IHttpResult - { - /// <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; } - private readonly ILogger _logger; - - private const int BufferSize = 81920; - - /// <summary> - /// The _options - /// </summary> - private readonly Dictionary<string, string> _options = new Dictionary<string, string>(); - - /// <summary> - /// The us culture - /// </summary> - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - - /// <summary> - /// Additional HTTP Headers - /// </summary> - /// <value>The headers.</value> - public IDictionary<string, string> Headers => _options; - - /// <summary> - /// Initializes a new instance of the <see cref="StreamWriter" /> class. - /// </summary> - /// <param name="rangeHeader">The range header.</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, ILogger logger) - { - if (string.IsNullOrEmpty(contentType)) - { - throw new ArgumentNullException(nameof(contentType)); - } - - RangeHeader = rangeHeader; - SourceStream = source; - IsHeadRequest = isHeadRequest; - this._logger = logger; - - ContentType = contentType; - Headers["Content-Type"] = contentType; - Headers["Accept-Ranges"] = "bytes"; - StatusCode = HttpStatusCode.PartialContent; - - SetRangeValues(contentLength); - } - - /// <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; - - // Content-Length is the length of what we're serving, not the original content - Headers["Content-Length"] = RangeLength.ToString(UsCulture); - Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", RangeStart, RangeEnd, TotalContentLength); - - if (RangeStart > 0 && SourceStream.CanSeek) - { - SourceStream.Position = RangeStart; - } - } - - /// <summary> - /// The _requested ranges - /// </summary> - private List<KeyValuePair<long, long?>> _requestedRanges; - /// <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 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).ConfigureAwait(false); - } - else - { - await CopyToInternalAsync(source, responseStream, RangeLength).ConfigureAwait(false); - } - } - } - finally - { - if (OnComplete != null) - { - OnComplete(); - } - } - } - - private static async Task CopyToInternalAsync(Stream source, Stream destination, long copyLength) - { - var array = new byte[BufferSize]; - int bytesRead; - while ((bytesRead = await source.ReadAsync(array, 0, array.Length).ConfigureAwait(false)) != 0) - { - if (bytesRead == 0) - { - break; - } - - var bytesToCopy = Math.Min(bytesRead, copyLength); - - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToCopy)).ConfigureAwait(false); - - copyLength -= bytesToCopy; - - if (copyLength <= 0) - { - break; - } - } - } - - 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; - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/ResponseFilter.cs b/Emby.Server.Implementations/HttpServer/ResponseFilter.cs deleted file mode 100644 index da2bf983a..000000000 --- a/Emby.Server.Implementations/HttpServer/ResponseFilter.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Globalization; -using System.Text; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.HttpServer -{ - public class ResponseFilter - { - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - private readonly ILogger _logger; - - public ResponseFilter(ILogger logger) - { - _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, IResponse res, object dto) - { - // Try to prevent compatibility view - res.AddHeader("Access-Control-Allow-Headers", "Accept, Accept-Language, Authorization, Cache-Control, Content-Disposition, Content-Encoding, Content-Language, Content-Length, Content-MD5, Content-Range, Content-Type, 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"); - res.AddHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); - res.AddHeader("Access-Control-Allow-Origin", "*"); - - 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, " "); - error = RemoveControlCharacters(error); - - res.AddHeader("X-Application-Error-Code", error); - } - } - - if (dto is IHasHeaders hasHeaders) - { - if (!hasHeaders.Headers.ContainsKey("Server")) - { - hasHeaders.Headers["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("Content-Length", out string contentLength) - && !string.IsNullOrEmpty(contentLength)) - { - var length = long.Parse(contentLength, UsCulture); - - if (length > 0) - { - res.SetContentLength(length); - res.SendChunked = false; - } - } - } - } - - /// <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; - - var newString = new StringBuilder(); - - 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 499a334fc..e2ad07177 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,239 +1,43 @@ -using System; -using System.Linq; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; +#pragma warning disable CS1591 + +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; 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 { public class AuthService : IAuthService { - private readonly IServerConfigurationManager _config; - - public AuthService(IUserManager userManager, IAuthorizationContext authorizationContext, IServerConfigurationManager config, ISessionManager sessionManager, INetworkManager networkManager) - { - AuthorizationContext = authorizationContext; - _config = config; - SessionManager = sessionManager; - UserManager = userManager; - NetworkManager = networkManager; - } - - public IUserManager UserManager { get; private set; } - public IAuthorizationContext AuthorizationContext { get; private set; } - public ISessionManager SessionManager { get; private set; } - public INetworkManager NetworkManager { get; private set; } - - /// <summary> - /// Redirect the client to a specific URL if authentication failed. - /// If this property is null, simply `401 Unauthorized` is returned. - /// </summary> - public string HtmlRedirect { get; set; } + private readonly IAuthorizationContext _authorizationContext; - public void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues) + public AuthService( + IAuthorizationContext authorizationContext) { - ValidateUser(request, authAttribtues); + _authorizationContext = authorizationContext; } - private void ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues) + public async Task<AuthorizationInfo> Authenticate(HttpRequest request) { - // This code is executed before the service - var auth = AuthorizationContext.GetAuthorizationInfo(request); + var auth = await _authorizationContext.GetAuthorizationInfo(request).ConfigureAwait(false); - if (!IsExemptFromAuthenticationToken(auth, authAttribtues, request)) + if (!auth.HasToken) { - ValidateSecurityToken(request, auth.Token); + throw new AuthenticationException("Request does not contain a token."); } - if (authAttribtues.AllowLocalOnly && !request.IsLocal) + if (!auth.IsAuthenticated) { - throw new SecurityException("Operation not found."); + throw new SecurityException("Invalid token."); } - var user = auth.User; - - if (user == null & !auth.UserId.Equals(Guid.Empty)) + if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false) { - throw new SecurityException("User with Id " + auth.UserId + " not found"); + throw new SecurityException("User account has been disabled."); } - if (user != null) - { - ValidateUserAccess(user, request, authAttribtues, auth); - } - - var info = GetTokenInfo(request); - - if (!IsExemptFromRoles(auth, authAttribtues, request, info)) - { - var roles = authAttribtues.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); - } - } - - private void ValidateUserAccess(User user, IRequest request, - IAuthenticationAttributes authAttribtues, - AuthorizationInfo auth) - { - if (user.Policy.IsDisabled) - { - throw new SecurityException("User account has been disabled.") - { - SecurityExceptionType = SecurityExceptionType.Unauthenticated - }; - } - - if (!user.Policy.EnableRemoteAccess && !NetworkManager.IsInLocalNetwork(request.RemoteIp)) - { - throw new SecurityException("User account has been disabled.") - { - SecurityExceptionType = SecurityExceptionType.Unauthenticated - }; - } - - if (!user.Policy.IsAdministrator && - !authAttribtues.EscapeParentalControl && - !user.IsParentalScheduleAllowed()) - { - request.Response.AddHeader("X-Application-Error-Code", "ParentalControl"); - - throw new SecurityException("This user account is not allowed access at this time.") - { - SecurityExceptionType = SecurityExceptionType.ParentalControl - }; - } - } - - private bool IsExemptFromAuthenticationToken(AuthorizationInfo auth, 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; - } - - 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.Policy.IsAdministrator) - { - throw new SecurityException("User does not have admin access.") - { - SecurityExceptionType = SecurityExceptionType.Unauthenticated - }; - } - } - if (roles.Contains("delete", StringComparer.OrdinalIgnoreCase)) - { - if (user == null || !user.Policy.EnableContentDeletion) - { - throw new SecurityException("User does not have delete access.") - { - SecurityExceptionType = SecurityExceptionType.Unauthenticated - }; - } - } - if (roles.Contains("download", StringComparer.OrdinalIgnoreCase)) - { - if (user == null || !user.Policy.EnableContentDownloading) - { - throw new SecurityException("User does not have download access.") - { - SecurityExceptionType = SecurityExceptionType.Unauthenticated - }; - } - } - } - - 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 SecurityException("Access token is required."); - } - - var info = GetTokenInfo(request); - - if (info == null) - { - throw new SecurityException("Access token is invalid or expired."); - } - - //if (!string.IsNullOrEmpty(info.UserId)) - //{ - // var user = _userManager.GetUserById(info.UserId); - - // if (user == null || user.Configuration.IsDisabled) - // { - // throw new SecurityException("User account has been disabled."); - // } - //} + return auth; } } } diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs deleted file mode 100644 index cab41e65b..000000000 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.HttpServer.Security -{ - public class AuthorizationContext : IAuthorizationContext - { - private readonly IAuthenticationRepository _authRepo; - private readonly IUserManager _userManager; - - public AuthorizationContext(IAuthenticationRepository authRepo, IUserManager userManager) - { - _authRepo = authRepo; - _userManager = userManager; - } - - public AuthorizationInfo GetAuthorizationInfo(object requestContext) - { - return GetAuthorizationInfo((IRequest)requestContext); - } - - public AuthorizationInfo GetAuthorizationInfo(IRequest requestContext) - { - if (requestContext.Items.TryGetValue("AuthorizationInfo", out var cached)) - { - return (AuthorizationInfo)cached; - } - - return GetAuthorization(requestContext); - } - - /// <summary> - /// Gets the authorization. - /// </summary> - /// <param name="httpReq">The HTTP req.</param> - /// <returns>Dictionary{System.StringSystem.String}.</returns> - private AuthorizationInfo GetAuthorization(IRequest httpReq) - { - var auth = GetAuthorizationDictionary(httpReq); - - string deviceId = null; - string device = null; - string client = null; - string version = null; - string token = null; - - if (auth != null) - { - auth.TryGetValue("DeviceId", out deviceId); - auth.TryGetValue("Device", out device); - auth.TryGetValue("Client", out client); - auth.TryGetValue("Version", out version); - auth.TryGetValue("Token", out token); - } - - if (string.IsNullOrEmpty(token)) - { - token = httpReq.Headers["X-Emby-Token"]; - } - - if (string.IsNullOrEmpty(token)) - { - token = httpReq.Headers["X-MediaBrowser-Token"]; - } - if (string.IsNullOrEmpty(token)) - { - token = httpReq.QueryString["api_key"]; - } - - var info = new AuthorizationInfo - { - Client = client, - Device = device, - DeviceId = deviceId, - Version = version, - Token = token - }; - - if (!string.IsNullOrWhiteSpace(token)) - { - var result = _authRepo.Get(new AuthenticationInfoQuery - { - AccessToken = token - }); - - var tokenInfo = result.Items.Length > 0 ? result.Items[0] : null; - - if (tokenInfo != null) - { - var updateToken = false; - - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(info.Client)) - { - info.Client = tokenInfo.AppName; - } - - if (string.IsNullOrWhiteSpace(info.DeviceId)) - { - info.DeviceId = tokenInfo.DeviceId; - } - - // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = info.Client == null || info.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; - - if (string.IsNullOrWhiteSpace(info.Device)) - { - info.Device = tokenInfo.DeviceName; - } - - else if (!string.Equals(info.Device, tokenInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - tokenInfo.DeviceName = info.Device; - } - } - - if (string.IsNullOrWhiteSpace(info.Version)) - { - info.Version = tokenInfo.AppVersion; - } - else if (!string.Equals(info.Version, tokenInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - tokenInfo.AppVersion = info.Version; - } - } - - if ((DateTime.UtcNow - tokenInfo.DateLastActivity).TotalMinutes > 3) - { - tokenInfo.DateLastActivity = DateTime.UtcNow; - updateToken = true; - } - - if (!tokenInfo.UserId.Equals(Guid.Empty)) - { - info.User = _userManager.GetUserById(tokenInfo.UserId); - - if (info.User != null && !string.Equals(info.User.Name, tokenInfo.UserName, StringComparison.OrdinalIgnoreCase)) - { - tokenInfo.UserName = info.User.Name; - updateToken = true; - } - } - - if (updateToken) - { - _authRepo.Update(tokenInfo); - } - } - httpReq.Items["OriginalAuthenticationInfo"] = tokenInfo; - } - - httpReq.Items["AuthorizationInfo"] = info; - - return info; - } - - /// <summary> - /// Gets the auth. - /// </summary> - /// <param name="httpReq">The HTTP req.</param> - /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorizationDictionary(IRequest httpReq) - { - var auth = httpReq.Headers["X-Emby-Authorization"]; - - if (string.IsNullOrEmpty(auth)) - { - auth = httpReq.Headers["Authorization"]; - } - - return GetAuthorization(auth); - } - - /// <summary> - /// Gets the authorization. - /// </summary> - /// <param name="authorizationHeader">The authorization header.</param> - /// <returns>Dictionary{System.StringSystem.String}.</returns> - private Dictionary<string, string> GetAuthorization(string authorizationHeader) - { - if (authorizationHeader == null) return null; - - var parts = authorizationHeader.Split(new[] { ' ' }, 2); - - // There should be at least to parts - if (parts.Length != 2) return null; - - var acceptedNames = new[] { "MediaBrowser", "Emby" }; - - // It has to be a digest request - if (!acceptedNames.Contains(parts[0] ?? string.Empty, StringComparer.OrdinalIgnoreCase)) - { - return null; - } - - // Remove uptil the first space - authorizationHeader = parts[1]; - parts = authorizationHeader.Split(','); - - var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - foreach (var item in parts) - { - var param = item.Trim().Split(new[] { '=' }, 2); - - if (param.Length == 2) - { - var value = NormalizeValue(param[1].Trim(new[] { '"' })); - result.Add(param[0], value); - } - } - - return result; - } - - private static string NormalizeValue(string value) - { - if (string.IsNullOrEmpty(value)) - { - return value; - } - - return System.Net.WebUtility.HtmlEncode(value); - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs index 81e11d312..a7647caf9 100644 --- a/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/SessionContext.cs @@ -1,10 +1,13 @@ +#pragma warning disable CS1591 + using System; -using MediaBrowser.Controller.Entities; +using System.Threading.Tasks; +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 { @@ -21,35 +24,35 @@ namespace Emby.Server.Implementations.HttpServer.Security _sessionManager = sessionManager; } - public SessionInfo GetSession(IRequest requestContext) + public async Task<SessionInfo> GetSession(HttpContext requestContext) { - var authorization = _authContext.GetAuthorizationInfo(requestContext); + var authorization = await _authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false); 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 await _sessionManager.LogSessionActivity( + authorization.Client, + authorization.Version, + authorization.DeviceId, + authorization.Device, + requestContext.GetNormalizedRemoteIp().ToString(), + user).ConfigureAwait(false); } - public SessionInfo GetSession(object requestContext) + public Task<SessionInfo> GetSession(object requestContext) { - return GetSession((IRequest)requestContext); + return GetSession((HttpContext)requestContext); } - public User GetUser(IRequest requestContext) + public async Task<User?> GetUser(HttpContext requestContext) { - var session = GetSession(requestContext); + var session = await GetSession(requestContext).ConfigureAwait(false); return session == null || session.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(session.UserId); } - public User GetUser(object requestContext) + public Task<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 cb2e3580b..000000000 --- a/Emby.Server.Implementations/HttpServer/StreamWriter.cs +++ /dev/null @@ -1,119 +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.Extensions.Logging; - -namespace Emby.Server.Implementations.HttpServer -{ - /// <summary> - /// Class StreamWriter - /// </summary> - public class StreamWriter : IAsyncStreamWriter, IHasHeaders - { - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - - /// <summary> - /// Gets or sets the source stream. - /// </summary> - /// <value>The source stream.</value> - private Stream SourceStream { get; set; } - - private byte[] SourceBytes { get; set; } - - /// <summary> - /// The _options - /// </summary> - private readonly IDictionary<string, string> _options = new Dictionary<string, string>(); - /// <summary> - /// Gets the options. - /// </summary> - /// <value>The options.</value> - public IDictionary<string, string> Headers => _options; - - public Action OnComplete { get; set; } - public Action OnError { get; set; } - - /// <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="logger">The logger.</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["Content-Length"] = source.Length.ToString(UsCulture); - } - } - - /// <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="logger">The logger.</param> - public StreamWriter(byte[] source, string contentType, int contentLength) - { - if (string.IsNullOrEmpty(contentType)) - { - throw new ArgumentNullException(nameof(contentType)); - } - - SourceBytes = source; - - Headers["Content-Type"] = contentType; - - Headers["Content-Length"] = contentLength.ToString(UsCulture); - } - - public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken) - { - try - { - var bytes = SourceBytes; - - if (bytes != null) - { - await responseStream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); - } - else - { - using (var src = SourceStream) - { - await src.CopyToAsync(responseStream).ConfigureAwait(false); - } - } - } - catch - { - if (OnError != null) - { - OnError(); - } - - throw; - } - finally - { - if (OnComplete != null) - { - OnComplete(); - } - } - } - } -} diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index e9d0bac74..5f25f6980 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -1,50 +1,76 @@ -using System; +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 Emby.Server.Implementations.Net; +using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; +using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using UtfUnknown; namespace Emby.Server.Implementations.HttpServer { /// <summary> - /// Class WebSocketConnection + /// Class WebSocketConnection. /// </summary> - public class WebSocketConnection : IWebSocketConnection + public class WebSocketConnection : IWebSocketConnection, IDisposable { - public event EventHandler<EventArgs> Closed; + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<WebSocketConnection> _logger; /// <summary> - /// The _socket + /// The json serializer options. /// </summary> - private readonly IWebSocket _socket; + private readonly JsonSerializerOptions _jsonOptions; /// <summary> - /// The _remote end point + /// The socket. /// </summary> - public string RemoteEndPoint { get; private set; } + private readonly WebSocket _socket; /// <summary> - /// The logger + /// Initializes a new instance of the <see cref="WebSocketConnection" /> class. /// </summary> - private readonly ILogger _logger; + /// <param name="logger">The logger.</param> + /// <param name="socket">The socket.</param> + /// <param name="remoteEndPoint">The remote end point.</param> + /// <param name="query">The query.</param> + public WebSocketConnection( + ILogger<WebSocketConnection> logger, + WebSocket socket, + IPAddress? remoteEndPoint, + IQueryCollection query) + { + _logger = logger; + _socket = socket; + RemoteEndPoint = remoteEndPoint; + QueryString = query; + + _jsonOptions = JsonDefaults.Options; + LastActivityDate = DateTime.Now; + } + + /// <inheritdoc /> + public event EventHandler<EventArgs>? Closed; /// <summary> - /// The _json serializer + /// Gets the remote end point. /// </summary> - private readonly IJsonSerializer _jsonSerializer; + public IPAddress? RemoteEndPoint { get; } /// <summary> /// Gets or sets the receive action. /// </summary> /// <value>The receive action.</value> - public Func<WebSocketMessageInfo, Task> OnReceive { get; set; } + public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; } /// <summary> /// Gets the last activity date. @@ -52,221 +78,172 @@ namespace Emby.Server.Implementations.HttpServer /// <value>The last activity date.</value> public DateTime LastActivityDate { get; private set; } - /// <summary> - /// Gets the id. - /// </summary> - /// <value>The id.</value> - public Guid Id { get; private set; } + /// <inheritdoc /> + public DateTime LastKeepAliveDate { get; set; } /// <summary> - /// Gets or sets the URL. + /// Gets the query string. /// </summary> - /// <value>The URL.</value> - public string Url { get; set; } + /// <value>The query string.</value> + public IQueryCollection QueryString { get; } + /// <summary> - /// Gets or sets the query string. + /// Gets the state. /// </summary> - /// <value>The query string.</value> - public QueryParamCollection QueryString { get; set; } + /// <value>The state.</value> + public WebSocketState State => _socket.State; /// <summary> - /// Initializes a new instance of the <see cref="WebSocketConnection" /> class. + /// Sends a message asynchronously. /// </summary> - /// <param name="socket">The socket.</param> - /// <param name="remoteEndPoint">The remote end point.</param> - /// <param name="jsonSerializer">The json serializer.</param> - /// <param name="logger">The logger.</param> - /// <exception cref="ArgumentNullException">socket</exception> - public WebSocketConnection(IWebSocket socket, string remoteEndPoint, IJsonSerializer jsonSerializer, ILogger logger) + /// <typeparam name="T">The type of the message.</typeparam> + /// <param name="message">The message.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendAsync<T>(WebSocketMessage<T> message, CancellationToken cancellationToken) { - if (socket == null) - { - throw new ArgumentNullException(nameof(socket)); - } - if (string.IsNullOrEmpty(remoteEndPoint)) - { - throw new ArgumentNullException(nameof(remoteEndPoint)); - } - if (jsonSerializer == null) - { - throw new ArgumentNullException(nameof(jsonSerializer)); - } - if (logger == null) - { - throw new ArgumentNullException(nameof(logger)); - } + var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); + return _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken); + } - Id = Guid.NewGuid(); - _jsonSerializer = jsonSerializer; - _socket = socket; - _socket.OnReceiveBytes = OnReceiveInternal; + /// <inheritdoc /> + public async Task ProcessAsync(CancellationToken cancellationToken = default) + { + var pipe = new Pipe(); + var writer = pipe.Writer; - var memorySocket = socket as IMemoryWebSocket; - if (memorySocket != null) + ValueWebSocketReceiveResult receiveresult; + do { - memorySocket.OnReceiveMemoryBytes = OnReceiveInternal; - } + // Allocate at least 512 bytes from the PipeWriter + Memory<byte> memory = writer.GetMemory(512); + try + { + receiveresult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false); + } + catch (WebSocketException ex) + { + _logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message); + break; + } - RemoteEndPoint = remoteEndPoint; - _logger = logger; + int bytesRead = receiveresult.Count; + if (bytesRead == 0) + { + break; + } - socket.Closed += socket_Closed; - } + // Tell the PipeWriter how much was read from the Socket + writer.Advance(bytesRead); - void socket_Closed(object sender, EventArgs e) - { - Closed?.Invoke(this, EventArgs.Empty); - } + // Make the data available to the PipeReader + FlushResult flushResult = await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + if (flushResult.IsCompleted) + { + // The PipeReader stopped reading + break; + } - /// <summary> - /// Called when [receive]. - /// </summary> - /// <param name="bytes">The bytes.</param> - private void OnReceiveInternal(byte[] bytes) - { - LastActivityDate = DateTime.UtcNow; + LastActivityDate = DateTime.UtcNow; - if (OnReceive == null) - { - return; + if (receiveresult.EndOfMessage) + { + await ProcessInternal(pipe.Reader).ConfigureAwait(false); + } } - var charset = CharsetDetector.DetectFromBytes(bytes).Detected?.EncodingName; + while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting) + && receiveresult.MessageType != WebSocketMessageType.Close); - if (string.Equals(charset, "utf-8", StringComparison.OrdinalIgnoreCase)) - { - OnReceiveInternal(Encoding.UTF8.GetString(bytes, 0, bytes.Length)); - } - else + Closed?.Invoke(this, EventArgs.Empty); + + if (_socket.State == WebSocketState.Open + || _socket.State == WebSocketState.CloseReceived + || _socket.State == WebSocketState.CloseSent) { - OnReceiveInternal(Encoding.ASCII.GetString(bytes, 0, bytes.Length)); + await _socket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + string.Empty, + cancellationToken).ConfigureAwait(false); } } - /// <summary> - /// Called when [receive]. - /// </summary> - /// <param name="memory">The memory block.</param> - /// <param name="length">The length of the memory block.</param> - private void OnReceiveInternal(Memory<byte> memory, int length) + private async Task ProcessInternal(PipeReader reader) { - LastActivityDate = DateTime.UtcNow; + ReadResult result = await reader.ReadAsync().ConfigureAwait(false); + ReadOnlySequence<byte> buffer = result.Buffer; if (OnReceive == null) { + // Tell the PipeReader how much of the buffer we have consumed + reader.AdvanceTo(buffer.End); return; } - var bytes = memory.Slice(0, length).ToArray(); - - var charset = CharsetDetector.DetectFromBytes(bytes).Detected?.EncodingName; - - if (string.Equals(charset, "utf-8", StringComparison.OrdinalIgnoreCase)) + WebSocketMessage<object>? stub; + long bytesConsumed = 0; + try { - OnReceiveInternal(Encoding.UTF8.GetString(bytes, 0, bytes.Length)); + stub = DeserializeWebSocketMessage(buffer, out bytesConsumed); } - else + catch (JsonException ex) { - OnReceiveInternal(Encoding.ASCII.GetString(bytes, 0, bytes.Length)); - } - } - - private void OnReceiveInternal(string message) - { - LastActivityDate = DateTime.UtcNow; - - if (!message.StartsWith("{", StringComparison.OrdinalIgnoreCase)) - { - // This info is useful sometimes but also clogs up the log - _logger.LogDebug("Received web socket message that is not a json structure: {message}", message); + // Tell the PipeReader how much of the buffer we have consumed + reader.AdvanceTo(buffer.End); + _logger.LogError(ex, "Error processing web socket message: {Data}", Encoding.UTF8.GetString(buffer)); return; } - if (OnReceive == null) + if (stub == null) { + _logger.LogError("Error processing web socket message"); return; } - try - { - var stub = (WebSocketMessage<object>)_jsonSerializer.DeserializeFromString(message, typeof(WebSocketMessage<object>)); + // Tell the PipeReader how much of the buffer we have consumed + reader.AdvanceTo(buffer.GetPosition(bytesConsumed)); - var info = new WebSocketMessageInfo - { - MessageType = stub.MessageType, - Data = stub.Data == null ? null : stub.Data.ToString(), - Connection = this - }; + _logger.LogDebug("WS {IP} received message: {@Message}", RemoteEndPoint, stub); - OnReceive(info); - } - catch (Exception ex) + if (stub.MessageType == SessionMessageType.KeepAlive) { - _logger.LogError(ex, "Error processing web socket message"); + await SendKeepAliveResponse().ConfigureAwait(false); } - } - - /// <summary> - /// Sends a message asynchronously. - /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="message">The message.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">message</exception> - public Task SendAsync<T>(WebSocketMessage<T> message, CancellationToken cancellationToken) - { - if (message == null) + else { - throw new ArgumentNullException(nameof(message)); + await OnReceive( + new WebSocketMessageInfo + { + MessageType = stub.MessageType, + Data = stub.Data?.ToString(), // Data can be null + Connection = this + }).ConfigureAwait(false); } - - var json = _jsonSerializer.SerializeToString(message); - - return SendAsync(json, cancellationToken); } - /// <summary> - /// Sends a message asynchronously. - /// </summary> - /// <param name="buffer">The buffer.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - public Task SendAsync(byte[] buffer, CancellationToken cancellationToken) + internal WebSocketMessage<object>? DeserializeWebSocketMessage(ReadOnlySequence<byte> bytes, out long bytesConsumed) { - if (buffer == null) - { - throw new ArgumentNullException(nameof(buffer)); - } - - cancellationToken.ThrowIfCancellationRequested(); - - return _socket.SendAsync(buffer, true, cancellationToken); + var jsonReader = new Utf8JsonReader(bytes); + var ret = JsonSerializer.Deserialize<WebSocketMessage<object>>(ref jsonReader, _jsonOptions); + bytesConsumed = jsonReader.BytesConsumed; + return ret; } - public Task SendAsync(string text, CancellationToken cancellationToken) + private Task SendKeepAliveResponse() { - if (string.IsNullOrEmpty(text)) - { - throw new ArgumentNullException(nameof(text)); - } - - cancellationToken.ThrowIfCancellationRequested(); - - return _socket.SendAsync(text, true, cancellationToken); + LastKeepAliveDate = DateTime.UtcNow; + return SendAsync( + new WebSocketMessage<string> + { + MessageId = Guid.NewGuid(), + MessageType = SessionMessageType.KeepAlive + }, CancellationToken.None); } - /// <summary> - /// Gets the state. - /// </summary> - /// <value>The state.</value> - public WebSocketState State => _socket.State; - - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> + /// <inheritdoc /> public void Dispose() { Dispose(true); + GC.SuppressFinalize(this); } /// <summary> diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs new file mode 100644 index 000000000..f86bfd755 --- /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) + { + _ = await _authService.Authenticate(context.Request).ConfigureAwait(false); + 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/ExtendedFileSystemInfo.cs b/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs index 48b34a3a0..545d73e05 100644 --- a/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs +++ b/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs @@ -1,9 +1,13 @@ +#pragma warning disable CS1591 + namespace Emby.Server.Implementations.IO { public class ExtendedFileSystemInfo { public bool IsHidden { get; set; } + public bool IsReadOnly { get; set; } + public bool Exists { get; set; } } } diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index 73242d0ad..47a83d77c 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.IO; @@ -6,37 +10,36 @@ using System.Threading; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Extensions; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.System; -using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.IO { public class FileRefresher : IDisposable { - private ILogger Logger { get; set; } - private ILibraryManager LibraryManager { get; set; } - private IServerConfigurationManager ConfigurationManager { get; set; } + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; + private readonly List<string> _affectedPaths = new List<string>(); - private Timer _timer; private readonly object _timerLock = new object(); - public string Path { get; private set; } - - public event EventHandler<EventArgs> Completed; + private Timer _timer; + private bool _disposed; public FileRefresher(string path, IServerConfigurationManager configurationManager, ILibraryManager libraryManager, ILogger logger) { logger.LogDebug("New file refresher created for {0}", path); Path = path; - ConfigurationManager = configurationManager; - LibraryManager = libraryManager; - Logger = logger; + _configurationManager = configurationManager; + _libraryManager = libraryManager; + _logger = logger; AddPath(path); } + public event EventHandler<EventArgs> Completed; + + public string Path { get; private set; } + private void AddAffectedPath(string path) { if (string.IsNullOrEmpty(path)) @@ -61,6 +64,7 @@ namespace Emby.Server.Implementations.IO { AddAffectedPath(path); } + RestartTimer(); } @@ -80,11 +84,11 @@ namespace Emby.Server.Implementations.IO if (_timer == null) { - _timer = new Timer(OnTimerCallback, null, TimeSpan.FromSeconds(ConfigurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1)); + _timer = new Timer(OnTimerCallback, null, TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1)); } else { - _timer.Change(TimeSpan.FromSeconds(ConfigurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1)); + _timer.Change(TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryMonitorDelay), TimeSpan.FromMilliseconds(-1)); } } } @@ -93,7 +97,7 @@ namespace Emby.Server.Implementations.IO { lock (_timerLock) { - Logger.LogDebug("Resetting file refresher from {0} to {1}", Path, path); + _logger.LogDebug("Resetting file refresher from {0} to {1}", Path, path); Path = path; AddAffectedPath(path); @@ -103,6 +107,7 @@ namespace Emby.Server.Implementations.IO AddAffectedPath(affectedFile); } } + RestartTimer(); } @@ -115,7 +120,7 @@ namespace Emby.Server.Implementations.IO paths = _affectedPaths.ToList(); } - Logger.LogDebug("Timer stopped."); + _logger.LogDebug("Timer stopped."); DisposeTimer(); Completed?.Invoke(this, EventArgs.Empty); @@ -126,7 +131,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) { - Logger.LogError(ex, "Error processing directory changes"); + _logger.LogError(ex, "Error processing directory changes"); } } @@ -146,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 { @@ -157,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); } } } @@ -177,7 +182,7 @@ namespace Emby.Server.Implementations.IO while (item == null && !string.IsNullOrEmpty(path)) { - item = LibraryManager.FindByPath(path, null); + item = _libraryManager.FindByPath(path, null); path = System.IO.Path.GetDirectoryName(path); } @@ -211,11 +216,12 @@ namespace Emby.Server.Implementations.IO } } - private bool _disposed; + /// <inheritdoc /> public void Dispose() { _disposed = true; DisposeTimer(); + GC.SuppressFinalize(this); } } } diff --git a/Emby.Server.Implementations/IO/IsoManager.cs b/Emby.Server.Implementations/IO/IsoManager.cs deleted file mode 100644 index f0a15097c..000000000 --- a/Emby.Server.Implementations/IO/IsoManager.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -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>IsoMount.</returns> - /// <exception cref="ArgumentNullException">isoPath</exception> - /// <exception cref="ArgumentException"></exception> - 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("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); - } - - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() - { - foreach (var mounter in _mounters) - { - mounter.Dispose(); - } - } - } -} diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index d47342511..e9d069cd3 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -1,67 +1,64 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.IO; -using MediaBrowser.Model.System; -using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.IO { public class LibraryMonitor : ILibraryMonitor { + private readonly ILogger<LibraryMonitor> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; + private readonly IFileSystem _fileSystem; + /// <summary> - /// The file system watchers + /// The file system watchers. /// </summary> private readonly ConcurrentDictionary<string, FileSystemWatcher> _fileSystemWatchers = new ConcurrentDictionary<string, FileSystemWatcher>(StringComparer.OrdinalIgnoreCase); + /// <summary> - /// The affected paths + /// The affected paths. /// </summary> private readonly List<FileRefresher> _activeRefreshers = new List<FileRefresher>(); /// <summary> - /// A dynamic list of paths that should be ignored. Added to during our own file sytem modifications. + /// A dynamic list of paths that should be ignored. Added to during our own file system modifications. /// </summary> private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase); + private bool _disposed = false; + /// <summary> - /// Any file name ending in any of these will be ignored by the watchers + /// Initializes a new instance of the <see cref="LibraryMonitor" /> class. /// </summary> - private static readonly HashSet<string> _alwaysIgnoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "small.jpg", - "albumart.jpg", - - // WMC temp recording directories that will constantly be written to - "TempRec", - "TempSBE" - }; - - private static readonly string[] _alwaysIgnoreSubstrings = new string[] - { - // Synology - "eaDir", - "#recycle", - ".wd_tv", - ".actors" - }; - - private static readonly HashSet<string> _alwaysIgnoreExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="configurationManager">The configuration manager.</param> + /// <param name="fileSystem">The filesystem.</param> + public LibraryMonitor( + ILogger<LibraryMonitor> logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem) { - // thumbs.db - ".db", - - // bts sync files - ".bts", - ".sync" - }; + _libraryManager = libraryManager; + _logger = logger; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + } /// <summary> /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope. @@ -97,7 +94,7 @@ namespace Emby.Server.Implementations.IO throw new ArgumentNullException(nameof(path)); } - // This is an arbitraty amount of time, but delay it because file system writes often trigger events long after the file was actually written to. + // This is an arbitrary amount of time, but delay it because file system writes often trigger events long after the file was actually written to. // Seeing long delays in some situations, especially over the network, sometimes up to 45 seconds // But if we make this delay too high, we risk missing legitimate changes, such as user adding a new file, or hand-editing metadata await Task.Delay(45000).ConfigureAwait(false); @@ -112,40 +109,11 @@ 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); } } } - /// <summary> - /// Gets or sets the logger. - /// </summary> - /// <value>The logger.</value> - private ILogger Logger { get; set; } - - private ILibraryManager LibraryManager { get; set; } - private IServerConfigurationManager ConfigurationManager { get; set; } - - private readonly IFileSystem _fileSystem; - private readonly IEnvironmentInfo _environmentInfo; - - /// <summary> - /// Initializes a new instance of the <see cref="LibraryMonitor" /> class. - /// </summary> - public LibraryMonitor( - ILoggerFactory loggerFactory, - ILibraryManager libraryManager, - IServerConfigurationManager configurationManager, - IFileSystem fileSystem, - IEnvironmentInfo environmentInfo) - { - LibraryManager = libraryManager; - Logger = loggerFactory.CreateLogger(GetType().Name); - ConfigurationManager = configurationManager; - _fileSystem = fileSystem; - _environmentInfo = environmentInfo; - } - private bool IsLibraryMonitorEnabled(BaseItem item) { if (item is BasePluginFolder) @@ -153,7 +121,7 @@ namespace Emby.Server.Implementations.IO return false; } - var options = LibraryManager.GetLibraryOptions(item); + var options = _libraryManager.GetLibraryOptions(item); if (options != null) { @@ -165,12 +133,12 @@ namespace Emby.Server.Implementations.IO public void Start() { - LibraryManager.ItemAdded += LibraryManager_ItemAdded; - LibraryManager.ItemRemoved += LibraryManager_ItemRemoved; + _libraryManager.ItemAdded += OnLibraryManagerItemAdded; + _libraryManager.ItemRemoved += OnLibraryManagerItemRemoved; - var pathsToWatch = new List<string> { }; + var pathsToWatch = new List<string>(); - var paths = LibraryManager + var paths = _libraryManager .RootFolder .Children .Where(IsLibraryMonitorEnabled) @@ -207,7 +175,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void LibraryManager_ItemRemoved(object sender, ItemChangeEventArgs e) + private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) { if (e.Parent is AggregateFolder) { @@ -220,7 +188,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - void LibraryManager_ItemAdded(object sender, ItemChangeEventArgs e) + private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) { if (e.Parent is AggregateFolder) { @@ -235,7 +203,7 @@ namespace Emby.Server.Implementations.IO /// <param name="lst">The LST.</param> /// <param name="path">The path.</param> /// <returns><c>true</c> if [contains parent folder] [the specified LST]; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">path</exception> + /// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception> private static bool ContainsParentFolder(IEnumerable<string> lst, string path) { if (string.IsNullOrEmpty(path)) @@ -247,7 +215,7 @@ namespace Emby.Server.Implementations.IO return lst.Any(str => { - //this should be a little quicker than examining each actual parent folder... + // this should be a little quicker than examining each actual parent folder... var compare = str.TrimEnd(Path.DirectorySeparatorChar); return path.Equals(compare, StringComparison.OrdinalIgnoreCase) || (path.StartsWith(compare, StringComparison.OrdinalIgnoreCase) && path[compare.Length] == Path.DirectorySeparatorChar); @@ -263,22 +231,7 @@ namespace Emby.Server.Implementations.IO if (!Directory.Exists(path)) { // Seeing a crash in the mono runtime due to an exception being thrown on a different thread - Logger.LogInformation("Skipping realtime monitor for {0} because the path does not exist", path); - return; - } - - if (_environmentInfo.OperatingSystem != MediaBrowser.Model.System.OperatingSystem.Windows) - { - if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase) || path.StartsWith("smb://", StringComparison.OrdinalIgnoreCase)) - { - // not supported - return; - } - } - - if (_environmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.Android) - { - // causing crashing + _logger.LogInformation("Skipping realtime monitor for {Path} because the path does not exist", path); return; } @@ -295,38 +248,35 @@ namespace Emby.Server.Implementations.IO { var newWatcher = new FileSystemWatcher(path, "*") { - IncludeSubdirectories = true + IncludeSubdirectories = true, + InternalBufferSize = 65536, + NotifyFilter = NotifyFilters.CreationTime | + NotifyFilters.DirectoryName | + NotifyFilters.FileName | + NotifyFilters.LastWrite | + NotifyFilters.Size | + NotifyFilters.Attributes }; - newWatcher.InternalBufferSize = 65536; - - newWatcher.NotifyFilter = NotifyFilters.CreationTime | - NotifyFilters.DirectoryName | - NotifyFilters.FileName | - NotifyFilters.LastWrite | - NotifyFilters.Size | - NotifyFilters.Attributes; - - newWatcher.Created += watcher_Changed; - newWatcher.Deleted += watcher_Changed; - newWatcher.Renamed += watcher_Changed; - newWatcher.Changed += watcher_Changed; - newWatcher.Error += watcher_Error; + newWatcher.Created += OnWatcherChanged; + newWatcher.Deleted += OnWatcherChanged; + newWatcher.Renamed += OnWatcherChanged; + newWatcher.Changed += OnWatcherChanged; + newWatcher.Error += OnWatcherError; if (_fileSystemWatchers.TryAdd(path, newWatcher)) { newWatcher.EnableRaisingEvents = true; - Logger.LogInformation("Watching directory " + path); + _logger.LogInformation("Watching directory " + path); } else { DisposeWatcher(newWatcher, false); } - } catch (Exception ex) { - Logger.LogError(ex, "Error watching path: {path}", path); + _logger.LogError(ex, "Error watching path: {path}", path); } }); } @@ -352,33 +302,17 @@ namespace Emby.Server.Implementations.IO { using (watcher) { - Logger.LogInformation("Stopping directory watching for path {path}", watcher.Path); + _logger.LogInformation("Stopping directory watching for path {Path}", watcher.Path); - watcher.Created -= watcher_Changed; - watcher.Deleted -= watcher_Changed; - watcher.Renamed -= watcher_Changed; - watcher.Changed -= watcher_Changed; - watcher.Error -= watcher_Error; + watcher.Created -= OnWatcherChanged; + watcher.Deleted -= OnWatcherChanged; + watcher.Renamed -= OnWatcherChanged; + watcher.Changed -= OnWatcherChanged; + watcher.Error -= OnWatcherError; - try - { - watcher.EnableRaisingEvents = false; - } - catch (InvalidOperationException) - { - // Seeing this under mono on linux sometimes - // Collection was modified; enumeration operation may not execute. - } + watcher.EnableRaisingEvents = false; } } - catch (NotImplementedException) - { - // the dispose method on FileSystemWatcher is sometimes throwing NotImplementedException on Xamarin Android - } - catch - { - - } finally { if (removeFromList) @@ -394,7 +328,7 @@ namespace Emby.Server.Implementations.IO /// <param name="watcher">The watcher.</param> private void RemoveWatcherFromList(FileSystemWatcher watcher) { - _fileSystemWatchers.TryRemove(watcher.Path, out var removed); + _fileSystemWatchers.TryRemove(watcher.Path, out _); } /// <summary> @@ -402,12 +336,12 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ErrorEventArgs" /> instance containing the event data.</param> - void watcher_Error(object sender, ErrorEventArgs e) + private void OnWatcherError(object sender, ErrorEventArgs e) { var ex = e.GetException(); var dw = (FileSystemWatcher)sender; - Logger.LogError(ex, "Error in Directory watcher for: {path}", dw.Path); + _logger.LogError(ex, "Error in Directory watcher for: {Path}", dw.Path); DisposeWatcher(dw, true); } @@ -417,19 +351,15 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing the event data.</param> - void watcher_Changed(object sender, FileSystemEventArgs e) + private void OnWatcherChanged(object sender, FileSystemEventArgs e) { try { - //logger.LogDebug("Changed detected of type " + e.ChangeType + " to " + e.FullPath); - - var path = e.FullPath; - - ReportFileSystemChanged(path); + ReportFileSystemChanged(e.FullPath); } catch (Exception ex) { - Logger.LogError(ex, "Exception in ReportFileSystemChanged. Path: {FullPath}", e.FullPath); + _logger.LogError(ex, "Exception in ReportFileSystemChanged. Path: {FullPath}", e.FullPath); } } @@ -440,12 +370,7 @@ namespace Emby.Server.Implementations.IO throw new ArgumentNullException(nameof(path)); } - var filename = Path.GetFileName(path); - - var monitorPath = !string.IsNullOrEmpty(filename) && - !_alwaysIgnoreFiles.Contains(filename) && - !_alwaysIgnoreExtensions.Contains(Path.GetExtension(path)) && - _alwaysIgnoreSubstrings.All(i => path.IndexOf(i, StringComparison.OrdinalIgnoreCase) == -1); + var monitorPath = !IgnorePatterns.ShouldIgnore(path); // Ignore certain files var tempIgnorePaths = _tempIgnoredPaths.Keys.ToList(); @@ -455,29 +380,25 @@ namespace Emby.Server.Implementations.IO { if (_fileSystem.AreEqual(i, path)) { - Logger.LogDebug("Ignoring change to {path}", path); + _logger.LogDebug("Ignoring change to {Path}", path); return true; } if (_fileSystem.ContainsSubPath(i, path)) { - Logger.LogDebug("Ignoring change to {path}", path); + _logger.LogDebug("Ignoring change to {Path}", path); return true; } // Go up a level var parent = Path.GetDirectoryName(i); - if (!string.IsNullOrEmpty(parent)) + if (!string.IsNullOrEmpty(parent) && _fileSystem.AreEqual(parent, path)) { - if (_fileSystem.AreEqual(parent, path)) - { - Logger.LogDebug("Ignoring change to {path}", path); - return true; - } + _logger.LogDebug("Ignoring change to {Path}", path); + return true; } return false; - })) { monitorPath = false; @@ -496,8 +417,7 @@ namespace Emby.Server.Implementations.IO lock (_activeRefreshers) { - var refreshers = _activeRefreshers.ToList(); - foreach (var refresher in refreshers) + foreach (var refresher in _activeRefreshers) { // Path is already being refreshed if (_fileSystem.AreEqual(path, refresher.Path)) @@ -528,7 +448,7 @@ namespace Emby.Server.Implementations.IO } } - var newRefresher = new FileRefresher(path, ConfigurationManager, LibraryManager, Logger); + var newRefresher = new FileRefresher(path, _configurationManager, _libraryManager, _logger); newRefresher.Completed += NewRefresher_Completed; _activeRefreshers.Add(newRefresher); } @@ -545,8 +465,8 @@ namespace Emby.Server.Implementations.IO /// </summary> public void Stop() { - LibraryManager.ItemAdded -= LibraryManager_ItemAdded; - LibraryManager.ItemRemoved -= LibraryManager_ItemRemoved; + _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; + _libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; foreach (var watcher in _fileSystemWatchers.Values.ToList()) { @@ -574,17 +494,18 @@ namespace Emby.Server.Implementations.IO { refresher.Dispose(); } + _activeRefreshers.Clear(); } } - private bool _disposed; /// <summary> /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// </summary> public void Dispose() { Dispose(true); + GC.SuppressFinalize(this); } /// <summary> @@ -606,24 +527,4 @@ namespace Emby.Server.Implementations.IO _disposed = true; } } - - public class LibraryMonitorStartup : IServerEntryPoint - { - private readonly ILibraryMonitor _monitor; - - public LibraryMonitorStartup(ILibraryMonitor monitor) - { - _monitor = monitor; - } - - public Task RunAsync() - { - _monitor.Start(); - return Task.CompletedTask; - } - - public void Dispose() - { - } - } } diff --git a/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs b/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs new file mode 100644 index 000000000..c51cf0545 --- /dev/null +++ b/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Plugins; + +namespace Emby.Server.Implementations.IO +{ + /// <summary> + /// <see cref="IServerEntryPoint" /> which is responsible for starting the library monitor. + /// </summary> + public sealed class LibraryMonitorStartup : IServerEntryPoint + { + private readonly ILibraryMonitor _monitor; + + /// <summary> + /// Initializes a new instance of the <see cref="LibraryMonitorStartup"/> class. + /// </summary> + /// <param name="monitor">The library monitor.</param> + public LibraryMonitorStartup(ILibraryMonitor monitor) + { + _monitor = monitor; + } + + /// <inheritdoc /> + public Task RunAsync() + { + _monitor.Start(); + return Task.CompletedTask; + } + + /// <inheritdoc /> + public void Dispose() + { + } + } +} diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index a64dfb607..eeee28842 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -1,46 +1,34 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; -using System.Text; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Model.IO; -using MediaBrowser.Model.System; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.IO { /// <summary> - /// Class ManagedFileSystem + /// Class ManagedFileSystem. /// </summary> public class ManagedFileSystem : IFileSystem { - protected ILogger Logger; + private readonly ILogger<ManagedFileSystem> _logger; - private readonly bool _supportsAsyncFileStreams; - private char[] _invalidFileNameChars; private readonly List<IShortcutHandler> _shortcutHandlers = new List<IShortcutHandler>(); - private readonly string _tempPath; - - private readonly IEnvironmentInfo _environmentInfo; - private readonly bool _isEnvironmentCaseInsensitive; + private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); public ManagedFileSystem( - ILoggerFactory loggerFactory, - IEnvironmentInfo environmentInfo, + ILogger<ManagedFileSystem> logger, IApplicationPaths applicationPaths) { - Logger = loggerFactory.CreateLogger("FileSystem"); - _supportsAsyncFileStreams = true; + _logger = logger; _tempPath = applicationPaths.TempDirectory; - _environmentInfo = environmentInfo; - - SetInvalidFileNameChars(environmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.Windows); - - _isEnvironmentCaseInsensitive = environmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.Windows; } public virtual void AddShortcutHandler(IShortcutHandler handler) @@ -48,26 +36,12 @@ namespace Emby.Server.Implementations.IO _shortcutHandlers.Add(handler); } - protected void SetInvalidFileNameChars(bool enableManagedInvalidFileNameChars) - { - if (enableManagedInvalidFileNameChars) - { - _invalidFileNameChars = Path.GetInvalidFileNameChars(); - } - else - { - // Be consistent across platforms because the windows server will fail to query network shares that don't follow windows conventions - // https://referencesource.microsoft.com/#mscorlib/system/io/path.cs - _invalidFileNameChars = new char[] { '\"', '<', '>', '|', '\0', (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10, (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20, (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30, (char)31, ':', '*', '?', '\\', '/' }; - } - } - /// <summary> /// Determines whether the specified filename is shortcut. /// </summary> /// <param name="filename">The filename.</param> /// <returns><c>true</c> if the specified filename is shortcut; otherwise, <c>false</c>.</returns> - /// <exception cref="ArgumentNullException">filename</exception> + /// <exception cref="ArgumentNullException"><paramref name="filename"/> is <c>null</c>.</exception> public virtual bool IsShortcut(string filename) { if (string.IsNullOrEmpty(filename)) @@ -84,8 +58,8 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">filename</exception> - public virtual string ResolveShortcut(string filename) + /// <exception cref="ArgumentNullException"><paramref name="filename"/> is <c>null</c>.</exception> + public virtual string? ResolveShortcut(string filename) { if (string.IsNullOrEmpty(filename)) { @@ -93,25 +67,27 @@ 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)); - if (handler != null) - { - return handler.Resolve(filename); - } - - return null; + return handler?.Resolve(filename); } public virtual string MakeAbsolutePath(string folderPath, string filePath) { - if (string.IsNullOrWhiteSpace(filePath)) return filePath; + // path is actually a stream + if (string.IsNullOrWhiteSpace(filePath) || filePath.Contains("://", StringComparison.Ordinal)) + { + return filePath; + } - if (filePath.Contains(@"://")) return filePath; //stream - if (filePath.Length > 3 && filePath[1] == ':' && filePath[2] == '/') return filePath; //absolute local path + if (filePath.Length > 3 && filePath[1] == ':' && filePath[2] == '/') + { + // absolute local path + return filePath; + } // unc path - if (filePath.StartsWith("\\\\")) + if (filePath.StartsWith("\\\\", StringComparison.Ordinal)) { return filePath; } @@ -119,18 +95,19 @@ namespace Emby.Server.Implementations.IO var firstChar = filePath[0]; if (firstChar == '/') { - // For this we don't really know. + // for this we don't really know return filePath; } - if (firstChar == '\\') //relative path + + // relative path + if (firstChar == '\\') { filePath = filePath.Substring(1); } + try { - string path = System.IO.Path.Combine(folderPath, filePath); - path = System.IO.Path.GetFullPath(path); - return path; + return Path.GetFullPath(Path.Combine(folderPath, filePath)); } catch (ArgumentException) { @@ -151,11 +128,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="shortcutPath">The shortcut path.</param> /// <param name="target">The target.</param> - /// <exception cref="ArgumentNullException"> - /// shortcutPath - /// or - /// target - /// </exception> + /// <exception cref="ArgumentNullException">The shortcutPath or target is null.</exception> public virtual void CreateShortcut(string shortcutPath, string target) { if (string.IsNullOrEmpty(shortcutPath)) @@ -169,7 +142,7 @@ namespace Emby.Server.Implementations.IO } var extension = Path.GetExtension(shortcutPath); - var handler = _shortcutHandlers.FirstOrDefault(i => string.Equals(extension, i.Extension, StringComparison.OrdinalIgnoreCase)); + var handler = _shortcutHandlers.Find(i => string.Equals(extension, i.Extension, StringComparison.OrdinalIgnoreCase)); if (handler != null) { @@ -247,27 +220,44 @@ namespace Emby.Server.Implementations.IO private FileSystemMetadata GetFileSystemMetadata(FileSystemInfo info) { - var result = new FileSystemMetadata(); - - result.Exists = info.Exists; - result.FullName = info.FullName; - result.Extension = info.Extension; - result.Name = info.Name; + var result = new FileSystemMetadata + { + Exists = info.Exists, + FullName = info.FullName, + Extension = info.Extension, + Name = info.Name + }; if (result.Exists) { result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory; - //if (!result.IsDirectory) - //{ + // if (!result.IsDirectory) + // { // result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden; - //} + // } - var fileInfo = info as FileInfo; - if (fileInfo != null) + if (info is FileInfo fileInfo) { result.Length = fileInfo.Length; - result.DirectoryName = fileInfo.DirectoryName; + + // 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) + { + try + { + using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + result.Length = RandomAccess.GetLength(fileHandle); + } + } + catch (FileNotFoundException ex) + { + // 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.CreationTimeUtc = GetCreationTimeUtc(info); @@ -301,21 +291,42 @@ namespace Emby.Server.Implementations.IO } /// <summary> - /// Takes a filename and removes invalid characters + /// Takes a filename and removes invalid characters. /// </summary> /// <param name="filename">The filename.</param> /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">filename</exception> - public virtual string GetValidFilename(string filename) + /// <exception cref="ArgumentNullException">The filename is null.</exception> + public string GetValidFilename(string filename) { - var builder = new StringBuilder(filename); - - foreach (var c in _invalidFileNameChars) + 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> @@ -332,7 +343,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) { - Logger.LogError(ex, "Error determining CreationTimeUtc for {FullName}", info.FullName); + _logger.LogError(ex, "Error determining CreationTimeUtc for {FullName}", info.FullName); return DateTime.MinValue; } } @@ -371,7 +382,7 @@ namespace Emby.Server.Implementations.IO } catch (Exception ex) { - Logger.LogError(ex, "Error determining LastAccessTimeUtc for {FullName}", info.FullName); + _logger.LogError(ex, "Error determining LastAccessTimeUtc for {FullName}", info.FullName); return DateTime.MinValue; } } @@ -386,90 +397,9 @@ namespace Emby.Server.Implementations.IO return GetLastWriteTimeUtc(GetFileSystemInfo(path)); } - /// <summary> - /// Gets the file stream. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="mode">The mode.</param> - /// <param name="access">The access.</param> - /// <param name="share">The share.</param> - /// <param name="isAsync">if set to <c>true</c> [is asynchronous].</param> - /// <returns>FileStream.</returns> - public virtual Stream GetFileStream(string path, FileOpenMode mode, FileAccessMode access, FileShareMode share, bool isAsync = false) - { - if (_supportsAsyncFileStreams && isAsync) - { - return GetFileStream(path, mode, access, share, FileOpenOptions.Asynchronous); - } - - return GetFileStream(path, mode, access, share, FileOpenOptions.None); - } - - public virtual Stream GetFileStream(string path, FileOpenMode mode, FileAccessMode access, FileShareMode share, FileOpenOptions fileOpenOptions) - => new FileStream(path, GetFileMode(mode), GetFileAccess(access), GetFileShare(share), 4096, GetFileOptions(fileOpenOptions)); - - private static FileOptions GetFileOptions(FileOpenOptions mode) - { - var val = (int)mode; - return (FileOptions)val; - } - - private static FileMode GetFileMode(FileOpenMode mode) - { - switch (mode) - { - //case FileOpenMode.Append: - // return FileMode.Append; - case FileOpenMode.Create: - return FileMode.Create; - case FileOpenMode.CreateNew: - return FileMode.CreateNew; - case FileOpenMode.Open: - return FileMode.Open; - case FileOpenMode.OpenOrCreate: - return FileMode.OpenOrCreate; - //case FileOpenMode.Truncate: - // return FileMode.Truncate; - default: - throw new Exception("Unrecognized FileOpenMode"); - } - } - - private static FileAccess GetFileAccess(FileAccessMode mode) - { - switch (mode) - { - //case FileAccessMode.ReadWrite: - // return FileAccess.ReadWrite; - case FileAccessMode.Write: - return FileAccess.Write; - case FileAccessMode.Read: - return FileAccess.Read; - default: - throw new Exception("Unrecognized FileAccessMode"); - } - } - - private static FileShare GetFileShare(FileShareMode mode) - { - switch (mode) - { - case FileShareMode.ReadWrite: - return FileShare.ReadWrite; - case FileShareMode.Write: - return FileShare.Write; - case FileShareMode.Read: - return FileShare.Read; - case FileShareMode.None: - return FileShare.None; - default: - throw new Exception("Unrecognized FileShareMode"); - } - } - public virtual void SetHidden(string path, bool isHidden) { - if (_environmentInfo.OperatingSystem != MediaBrowser.Model.System.OperatingSystem.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -491,33 +421,9 @@ namespace Emby.Server.Implementations.IO } } - public virtual void SetReadOnly(string path, bool isReadOnly) + public virtual void SetAttributes(string path, bool isHidden, bool readOnly) { - if (_environmentInfo.OperatingSystem != MediaBrowser.Model.System.OperatingSystem.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 (_environmentInfo.OperatingSystem != MediaBrowser.Model.System.OperatingSystem.Windows) + if (!OperatingSystem.IsWindows()) { return; } @@ -529,14 +435,14 @@ namespace Emby.Server.Implementations.IO return; } - if (info.IsReadOnly == isReadOnly && info.IsHidden == isHidden) + if (info.IsReadOnly == readOnly && info.IsHidden == isHidden) { return; } var attributes = File.GetAttributes(path); - if (isReadOnly) + if (readOnly) { attributes = attributes | FileAttributes.ReadOnly; } @@ -579,7 +485,7 @@ namespace Emby.Server.Implementations.IO throw new ArgumentNullException(nameof(file2)); } - var temp1 = Path.Combine(_tempPath, Guid.NewGuid().ToString("N")); + var temp1 = Path.Combine(_tempPath, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); // Copying over will fail against hidden files SetHidden(file1, false); @@ -604,26 +510,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) @@ -638,7 +527,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) @@ -653,7 +542,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) @@ -669,7 +561,6 @@ namespace Emby.Server.Implementations.IO public virtual bool IsPathFile(string path) { // Cannot use Path.IsPathRooted because it returns false under mono when using windows-based paths, e.g. C:\\ - if (path.IndexOf("://", StringComparison.OrdinalIgnoreCase) != -1 && !path.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) { @@ -677,8 +568,6 @@ namespace Emby.Server.Implementations.IO } return true; - - //return Path.IsPathRooted(path); } public virtual void DeleteFile(string path) @@ -689,21 +578,20 @@ namespace Emby.Server.Implementations.IO public virtual List<FileSystemMetadata> GetDrives() { - // Only include drives in the ready state or this method could end up being very slow, waiting for drives to timeout - return DriveInfo.GetDrives().Where(d => d.IsReady).Select(d => new FileSystemMetadata - { - Name = d.Name, - FullName = d.RootDirectory.FullName, - IsDirectory = true - - }).ToList(); + // check for ready state to avoid waiting for drives to timeout + // some drives on linux have no actual size or are used for other purposes + return DriveInfo.GetDrives().Where(d => d.IsReady && d.TotalSize != 0 && d.DriveType != DriveType.Ram) + .Select(d => new FileSystemMetadata + { + Name = d.Name, + FullName = d.RootDirectory.FullName, + IsDirectory = true + }).ToList(); } 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) @@ -711,29 +599,30 @@ namespace Emby.Server.Implementations.IO return GetFiles(path, null, false, recursive); } - public virtual IEnumerable<FileSystemMetadata> GetFiles(string path, 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.Length == 1) + 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.Length > 0) + 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); }); } @@ -743,10 +632,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) @@ -756,8 +644,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) @@ -765,29 +652,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); }); } @@ -796,31 +684,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); + return Directory.EnumerateFileSystemEntries(path, "*", GetEnumerationOptions(recursive)); } - public virtual void SetExecutable(string path) + private EnumerationOptions GetEnumerationOptions(bool recursive) { - if (_environmentInfo.OperatingSystem == MediaBrowser.Model.System.OperatingSystem.OSX) + return new EnumerationOptions { - RunProcess("chmod", "+x \"" + path + "\"", Path.GetDirectoryName(path)); - } - } - - private static void RunProcess(string path, string args, string workingDirectory) - { - using (var process = Process.Start(new ProcessStartInfo - { - 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 5e5e91bb3..76c58d5dc 100644 --- a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs +++ b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.IO; using MediaBrowser.Model.IO; @@ -15,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 09cf4d4a3..e4f5f4cf0 100644 --- a/Emby.Server.Implementations/IO/StreamHelper.cs +++ b/Emby.Server.Implementations/IO/StreamHelper.cs @@ -1,4 +1,7 @@ +#pragma warning disable CS1591 + using System; +using System.Buffers; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -8,168 +11,125 @@ namespace Emby.Server.Implementations.IO { public class StreamHelper : IStreamHelper { - 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 = new byte[bufferSize]; - int read; - while ((read = source.Read(buffer, 0, buffer.Length)) != 0) - { - cancellationToken.ThrowIfCancellationRequested(); - - await destination.WriteAsync(buffer, 0, read).ConfigureAwait(false); - - if (onStarted != null) - { - onStarted(); - onStarted = null; - } - } - } - - public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken) - { - byte[] buffer = new byte[bufferSize]; - - if (emptyReadLimit <= 0) + byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize); + try { int read; - while ((read = source.Read(buffer, 0, buffer.Length)) != 0) + while ((read = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) { cancellationToken.ThrowIfCancellationRequested(); - await destination.WriteAsync(buffer, 0, read).ConfigureAwait(false); - } + await destination.WriteAsync(buffer, 0, read, cancellationToken).ConfigureAwait(false); - return; + if (onStarted != null) + { + onStarted(); + onStarted = null; + } + } } - - var eofCount = 0; - - while (eofCount < emptyReadLimit) + finally { - cancellationToken.ThrowIfCancellationRequested(); - - var bytesRead = source.Read(buffer, 0, buffer.Length); - - if (bytesRead == 0) - { - eofCount++; - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } - else - { - eofCount = 0; - - await destination.WriteAsync(buffer, 0, bytesRead).ConfigureAwait(false); - } + ArrayPool<byte>.Shared.Return(buffer); } } - const int StreamCopyToBufferSize = 81920; - public async Task<int> CopyToAsync(Stream source, Stream destination, CancellationToken cancellationToken) + public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken) { - var array = new byte[StreamCopyToBufferSize]; - int bytesRead; - int totalBytesRead = 0; - - while ((bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false)) != 0) + byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize); + try { - var bytesToWrite = bytesRead; - - if (bytesToWrite > 0) + if (emptyReadLimit <= 0) { - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); + int read; + while ((read = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + await destination.WriteAsync(buffer, 0, read, cancellationToken).ConfigureAwait(false); + } - totalBytesRead += bytesRead; + return; } - } - return totalBytesRead; - } + var eofCount = 0; - public async Task<int> CopyToAsyncWithSyncRead(Stream source, Stream destination, CancellationToken cancellationToken) - { - var array = new byte[StreamCopyToBufferSize]; - int bytesRead; - int totalBytesRead = 0; + while (eofCount < emptyReadLimit) + { + cancellationToken.ThrowIfCancellationRequested(); - while ((bytesRead = source.Read(array, 0, array.Length)) != 0) - { - var bytesToWrite = bytesRead; + var bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); - if (bytesToWrite > 0) - { - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + { + eofCount++; + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + else + { + eofCount = 0; - totalBytesRead += bytesRead; + await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + } } } - - return totalBytesRead; - } - - public async Task CopyToAsyncWithSyncRead(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken) - { - var array = new byte[StreamCopyToBufferSize]; - int bytesRead; - - while ((bytesRead = source.Read(array, 0, array.Length)) != 0) + finally { - var bytesToWrite = Math.Min(bytesRead, copyLength); - - if (bytesToWrite > 0) - { - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); - } - - copyLength -= bytesToWrite; - - if (copyLength <= 0) - { - break; - } + ArrayPool<byte>.Shared.Return(buffer); } } public async Task CopyToAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken) { - var array = new byte[StreamCopyToBufferSize]; - int bytesRead; - - while ((bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false)) != 0) + byte[] buffer = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize); + try { - var bytesToWrite = Math.Min(bytesRead, copyLength); + int bytesRead; - if (bytesToWrite > 0) + while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) { - await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); - } + var bytesToWrite = Math.Min(bytesRead, copyLength); - copyLength -= bytesToWrite; + if (bytesToWrite > 0) + { + await destination.WriteAsync(buffer, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); + } - if (copyLength <= 0) - { - break; + copyLength -= bytesToWrite; + + if (copyLength <= 0) + { + break; + } } } + finally + { + ArrayPool<byte>.Shared.Return(buffer); + } } public async Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken) { - byte[] buffer = new byte[bufferSize]; - - while (!cancellationToken.IsCancellationRequested) + byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize); + try { - var bytesRead = await CopyToAsyncInternal(source, target, buffer, cancellationToken).ConfigureAwait(false); - - //var position = fs.Position; - //_logger.LogDebug("Streamed {0} bytes to position {1} from file {2}", bytesRead, position, path); - - if (bytesRead == 0) + while (!cancellationToken.IsCancellationRequested) { - await Task.Delay(100).ConfigureAwait(false); + var bytesRead = await CopyToAsyncInternal(source, target, buffer, cancellationToken).ConfigureAwait(false); + + if (bytesRead == 0) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } } } + finally + { + ArrayPool<byte>.Shared.Return(buffer); + } } private static async Task<int> CopyToAsyncInternal(Stream source, Stream destination, byte[] buffer, CancellationToken cancellationToken) @@ -179,7 +139,7 @@ namespace Emby.Server.Implementations.IO while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) { - await destination.WriteAsync(buffer, 0, bytesRead).ConfigureAwait(false); + await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); totalBytesRead += bytesRead; } diff --git a/Emby.Server.Implementations/IO/ThrottledStream.cs b/Emby.Server.Implementations/IO/ThrottledStream.cs deleted file mode 100644 index 81e8abc98..000000000 --- a/Emby.Server.Implementations/IO/ThrottledStream.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Emby.Server.Implementations.IO -{ - /// <summary> - /// Class for streaming data with throttling support. - /// </summary> - public class ThrottledStream : Stream - { - /// <summary> - /// A constant used to specify an infinite number of bytes that can be transferred per second. - /// </summary> - public const long Infinite = 0; - - #region Private members - /// <summary> - /// The base stream. - /// </summary> - private readonly Stream _baseStream; - - /// <summary> - /// The maximum bytes per second that can be transferred through the base stream. - /// </summary> - private long _maximumBytesPerSecond; - - /// <summary> - /// The number of bytes that has been transferred since the last throttle. - /// </summary> - private long _byteCount; - - /// <summary> - /// The start time in milliseconds of the last throttle. - /// </summary> - private long _start; - #endregion - - #region Properties - /// <summary> - /// Gets the current milliseconds. - /// </summary> - /// <value>The current milliseconds.</value> - protected long CurrentMilliseconds => Environment.TickCount; - - /// <summary> - /// Gets or sets the maximum bytes per second that can be transferred through the base stream. - /// </summary> - /// <value>The maximum bytes per second.</value> - public long MaximumBytesPerSecond - { - get => _maximumBytesPerSecond; - set - { - if (MaximumBytesPerSecond != value) - { - _maximumBytesPerSecond = value; - Reset(); - } - } - } - - /// <summary> - /// Gets a value indicating whether the current stream supports reading. - /// </summary> - /// <returns>true if the stream supports reading; otherwise, false.</returns> - public override bool CanRead => _baseStream.CanRead; - - /// <summary> - /// Gets a value indicating whether the current stream supports seeking. - /// </summary> - /// <value></value> - /// <returns>true if the stream supports seeking; otherwise, false.</returns> - public override bool CanSeek => _baseStream.CanSeek; - - /// <summary> - /// Gets a value indicating whether the current stream supports writing. - /// </summary> - /// <value></value> - /// <returns>true if the stream supports writing; otherwise, false.</returns> - public override bool CanWrite => _baseStream.CanWrite; - - /// <summary> - /// Gets the length in bytes of the stream. - /// </summary> - /// <value></value> - /// <returns>A long value representing the length of the stream in bytes.</returns> - /// <exception cref="T:System.NotSupportedException">The base stream does not support seeking. </exception> - /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception> - public override long Length => _baseStream.Length; - - /// <summary> - /// Gets or sets the position within the current stream. - /// </summary> - /// <value></value> - /// <returns>The current position within the stream.</returns> - /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception> - /// <exception cref="T:System.NotSupportedException">The base stream does not support seeking. </exception> - /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception> - public override long Position - { - get => _baseStream.Position; - set => _baseStream.Position = value; - } - #endregion - - public long MinThrottlePosition; - - #region Ctor - /// <summary> - /// Initializes a new instance of the <see cref="T:ThrottledStream"/> class. - /// </summary> - /// <param name="baseStream">The base stream.</param> - /// <param name="maximumBytesPerSecond">The maximum bytes per second that can be transferred through the base stream.</param> - /// <exception cref="ArgumentNullException">Thrown when <see cref="baseStream"/> is a null reference.</exception> - /// <exception cref="ArgumentOutOfRangeException">Thrown when <see cref="maximumBytesPerSecond"/> is a negative value.</exception> - public ThrottledStream(Stream baseStream, long maximumBytesPerSecond) - { - if (baseStream == null) - { - throw new ArgumentNullException(nameof(baseStream)); - } - - if (maximumBytesPerSecond < 0) - { - throw new ArgumentOutOfRangeException(nameof(maximumBytesPerSecond), - maximumBytesPerSecond, "The maximum number of bytes per second can't be negative."); - } - - _baseStream = baseStream; - _maximumBytesPerSecond = maximumBytesPerSecond; - _start = CurrentMilliseconds; - _byteCount = 0; - } - #endregion - - #region Public methods - /// <summary> - /// Clears all buffers for this stream and causes any buffered data to be written to the underlying device. - /// </summary> - /// <exception cref="T:System.IO.IOException">An I/O error occurs.</exception> - public override void Flush() - { - _baseStream.Flush(); - } - - /// <summary> - /// Reads a sequence of bytes from the current stream and advances the position within the stream by the number of bytes read. - /// </summary> - /// <param name="buffer">An array of bytes. When this method returns, the buffer contains the specified byte array with the values between offset and (offset + count - 1) replaced by the bytes read from the current source.</param> - /// <param name="offset">The zero-based byte offset in buffer at which to begin storing the data read from the current stream.</param> - /// <param name="count">The maximum number of bytes to be read from the current stream.</param> - /// <returns> - /// The total number of bytes read into the buffer. This can be less than the number of bytes requested if that many bytes are not currently available, or zero (0) if the end of the stream has been reached. - /// </returns> - /// <exception cref="T:System.ArgumentException">The sum of offset and count is larger than the buffer length. </exception> - /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception> - /// <exception cref="T:System.NotSupportedException">The base stream does not support reading. </exception> - /// <exception cref="T:System.ArgumentNullException">buffer is null. </exception> - /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception> - /// <exception cref="T:System.ArgumentOutOfRangeException">offset or count is negative. </exception> - public override int Read(byte[] buffer, int offset, int count) - { - Throttle(count); - - return _baseStream.Read(buffer, offset, count); - } - - /// <summary> - /// Sets the position within the current stream. - /// </summary> - /// <param name="offset">A byte offset relative to the origin parameter.</param> - /// <param name="origin">A value of type <see cref="T:System.IO.SeekOrigin"></see> indicating the reference point used to obtain the new position.</param> - /// <returns> - /// The new position within the current stream. - /// </returns> - /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception> - /// <exception cref="T:System.NotSupportedException">The base stream does not support seeking, such as if the stream is constructed from a pipe or console output. </exception> - /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception> - public override long Seek(long offset, SeekOrigin origin) - { - return _baseStream.Seek(offset, origin); - } - - /// <summary> - /// Sets the length of the current stream. - /// </summary> - /// <param name="value">The desired length of the current stream in bytes.</param> - /// <exception cref="T:System.NotSupportedException">The base stream does not support both writing and seeking, such as if the stream is constructed from a pipe or console output. </exception> - /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception> - /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception> - public override void SetLength(long value) - { - _baseStream.SetLength(value); - } - - private long _bytesWritten; - - /// <summary> - /// Writes a sequence of bytes to the current stream and advances the current position within this stream by the number of bytes written. - /// </summary> - /// <param name="buffer">An array of bytes. This method copies count bytes from buffer to the current stream.</param> - /// <param name="offset">The zero-based byte offset in buffer at which to begin copying bytes to the current stream.</param> - /// <param name="count">The number of bytes to be written to the current stream.</param> - /// <exception cref="T:System.IO.IOException">An I/O error occurs. </exception> - /// <exception cref="T:System.NotSupportedException">The base stream does not support writing. </exception> - /// <exception cref="T:System.ObjectDisposedException">Methods were called after the stream was closed. </exception> - /// <exception cref="T:System.ArgumentNullException">buffer is null. </exception> - /// <exception cref="T:System.ArgumentException">The sum of offset and count is greater than the buffer length. </exception> - /// <exception cref="T:System.ArgumentOutOfRangeException">offset or count is negative. </exception> - public override void Write(byte[] buffer, int offset, int count) - { - Throttle(count); - - _baseStream.Write(buffer, offset, count); - - _bytesWritten += count; - } - - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - await ThrottleAsync(count, cancellationToken).ConfigureAwait(false); - - await _baseStream.WriteAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); - - _bytesWritten += count; - } - - /// <summary> - /// Returns a <see cref="T:System.String"></see> that represents the current <see cref="T:System.Object"></see>. - /// </summary> - /// <returns> - /// A <see cref="T:System.String"></see> that represents the current <see cref="T:System.Object"></see>. - /// </returns> - public override string ToString() - { - return _baseStream.ToString(); - } - #endregion - - private bool ThrottleCheck(int bufferSizeInBytes) - { - if (_bytesWritten < MinThrottlePosition) - { - return false; - } - - // Make sure the buffer isn't empty. - if (_maximumBytesPerSecond <= 0 || bufferSizeInBytes <= 0) - { - return false; - } - - return true; - } - - #region Protected methods - /// <summary> - /// Throttles for the specified buffer size in bytes. - /// </summary> - /// <param name="bufferSizeInBytes">The buffer size in bytes.</param> - protected void Throttle(int bufferSizeInBytes) - { - if (!ThrottleCheck(bufferSizeInBytes)) - { - return; - } - - _byteCount += bufferSizeInBytes; - long elapsedMilliseconds = CurrentMilliseconds - _start; - - if (elapsedMilliseconds > 0) - { - // Calculate the current bps. - long bps = _byteCount * 1000L / elapsedMilliseconds; - - // If the bps are more then the maximum bps, try to throttle. - if (bps > _maximumBytesPerSecond) - { - // Calculate the time to sleep. - long wakeElapsed = _byteCount * 1000L / _maximumBytesPerSecond; - int toSleep = (int)(wakeElapsed - elapsedMilliseconds); - - if (toSleep > 1) - { - try - { - // The time to sleep is more then a millisecond, so sleep. - var task = Task.Delay(toSleep); - Task.WaitAll(task); - } - catch - { - // Eatup ThreadAbortException. - } - - // A sleep has been done, reset. - Reset(); - } - } - } - } - - protected async Task ThrottleAsync(int bufferSizeInBytes, CancellationToken cancellationToken) - { - if (!ThrottleCheck(bufferSizeInBytes)) - { - return; - } - - _byteCount += bufferSizeInBytes; - long elapsedMilliseconds = CurrentMilliseconds - _start; - - if (elapsedMilliseconds > 0) - { - // Calculate the current bps. - long bps = _byteCount * 1000L / elapsedMilliseconds; - - // If the bps are more then the maximum bps, try to throttle. - if (bps > _maximumBytesPerSecond) - { - // Calculate the time to sleep. - long wakeElapsed = _byteCount * 1000L / _maximumBytesPerSecond; - int toSleep = (int)(wakeElapsed - elapsedMilliseconds); - - if (toSleep > 1) - { - // The time to sleep is more then a millisecond, so sleep. - await Task.Delay(toSleep, cancellationToken).ConfigureAwait(false); - - // A sleep has been done, reset. - Reset(); - } - } - } - } - - /// <summary> - /// Will reset the bytecount to 0 and reset the start time to the current time. - /// </summary> - protected void Reset() - { - long difference = CurrentMilliseconds - _start; - - // Only reset counters when a known history is available of more then 1 second. - if (difference > 1000) - { - _byteCount = 0; - _start = CurrentMilliseconds; - } - } - #endregion - } -} diff --git a/Emby.Server.Implementations/IStartupOptions.cs b/Emby.Server.Implementations/IStartupOptions.cs index 24aaa76c0..3769ae4dd 100644 --- a/Emby.Server.Implementations/IStartupOptions.cs +++ b/Emby.Server.Implementations/IStartupOptions.cs @@ -1,40 +1,38 @@ namespace Emby.Server.Implementations { + /// <summary> + /// Specifies the contract for server startup options. + /// </summary> public interface IStartupOptions { /// <summary> - /// --ffmpeg + /// Gets the value of the --ffmpeg command line option. /// </summary> - string FFmpegPath { get; } + string? FFmpegPath { get; } /// <summary> - /// --ffprobe - /// </summary> - string FFprobePath { get; } - - /// <summary> - /// --service + /// Gets a value indicating whether to run as service by the --service command line option. /// </summary> bool IsService { get; } /// <summary> - /// --noautorunwebapp + /// Gets the value of the --package-name command line option. /// </summary> - bool NoAutoRunWebApp { get; } + string? PackageName { get; } /// <summary> - /// --package-name + /// Gets the value of the --restartpath command line option. /// </summary> - string PackageName { get; } + string? RestartPath { get; } /// <summary> - /// --restartpath + /// Gets the value of the --restartargs command line option. /// </summary> - string RestartPath { get; } + string? RestartArgs { get; } /// <summary> - /// --restartargs + /// Gets the value of the --published-server-url command line option. /// </summary> - string RestartArgs { get; } + string? PublishedServerUrl { get; } } } diff --git a/Emby.Server.Implementations/Images/ArtistImageProvider.cs b/Emby.Server.Implementations/Images/ArtistImageProvider.cs new file mode 100644 index 000000000..e96b64595 --- /dev/null +++ b/Emby.Server.Implementations/Images/ArtistImageProvider.cs @@ -0,0 +1,46 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + /// <summary> + /// Class ArtistImageProvider. + /// </summary> + public class ArtistImageProvider : BaseDynamicImageProvider<MusicArtist> + { + public ArtistImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) + : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + } + + /// <summary> + /// Get children objects used to create an artist image. + /// </summary> + /// <param name="item">The artist used to create the image.</param> + /// <returns>Any relevant children objects.</returns> + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) + { + return Array.Empty<BaseItem>(); + + // TODO enable this when BaseDynamicImageProvider objects are configurable + // return _libraryManager.GetItemList(new InternalItemsQuery + // { + // ArtistIds = new[] { item.Id }, + // IncludeItemTypes = new[] { nameof(MusicAlbum) }, + // OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, + // Limit = 4, + // Recursive = true, + // ImageTypes = new[] { ImageType.Primary }, + // DtoOptions = new DtoOptions(false) + // }); + } + } +} diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 109c21f18..758986945 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -1,5 +1,10 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading; @@ -20,11 +25,6 @@ namespace Emby.Server.Implementations.Images public abstract class BaseDynamicImageProvider<T> : IHasItemChangeMonitor, IForcedProvider, ICustomMetadataProvider<T>, IHasOrder where T : BaseItem { - protected IFileSystem FileSystem { get; private set; } - protected IProviderManager ProviderManager { get; private set; } - protected IApplicationPaths ApplicationPaths { get; private set; } - protected IImageProcessor ImageProcessor { get; set; } - protected BaseDynamicImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) { ApplicationPaths = applicationPaths; @@ -33,18 +33,25 @@ namespace Emby.Server.Implementations.Images ImageProcessor = imageProcessor; } - protected virtual bool Supports(BaseItem item) - { - return true; - } + protected IFileSystem FileSystem { get; } - public virtual ImageType[] GetSupportedImages(BaseItem item) - { - return new ImageType[] - { - ImageType.Primary - }; - } + protected IProviderManager ProviderManager { get; } + + protected IApplicationPaths ApplicationPaths { get; } + + protected IImageProcessor ImageProcessor { get; set; } + + protected virtual IReadOnlyCollection<ImageType> SupportedImages { get; } + = new ImageType[] { ImageType.Primary }; + + /// <inheritdoc /> + public string Name => "Dynamic Image Provider"; + + protected virtual int MaxImageAgeDays => 7; + + public int Order => 0; + + protected virtual bool Supports(BaseItem item) => true; public async Task<ItemUpdateType> FetchAsync(T item, MetadataRefreshOptions options, CancellationToken cancellationToken) { @@ -54,18 +61,17 @@ namespace Emby.Server.Implementations.Images } var updateType = ItemUpdateType.None; - var supportedImages = GetSupportedImages(item); - if (supportedImages.Contains(ImageType.Primary)) + if (SupportedImages.Contains(ImageType.Primary)) { var primaryResult = await FetchAsync(item, ImageType.Primary, options, cancellationToken).ConfigureAwait(false); - updateType = updateType | primaryResult; + updateType |= primaryResult; } - if (supportedImages.Contains(ImageType.Thumb)) + if (SupportedImages.Contains(ImageType.Thumb)) { var thumbResult = await FetchAsync(item, ImageType.Thumb, options, cancellationToken).ConfigureAwait(false); - updateType = updateType | thumbResult; + updateType |= thumbResult; } return updateType; @@ -93,12 +99,13 @@ namespace Emby.Server.Implementations.Images return FetchToFileInternal(item, items, imageType, cancellationToken); } - protected async Task<ItemUpdateType> FetchToFileInternal(BaseItem item, - List<BaseItem> itemsWithImages, + protected async Task<ItemUpdateType> FetchToFileInternal( + BaseItem item, + IReadOnlyList<BaseItem> itemsWithImages, ImageType imageType, CancellationToken cancellationToken) { - var outputPathWithoutExtension = Path.Combine(ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N")); + var outputPathWithoutExtension = Path.Combine(ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); Directory.CreateDirectory(Path.GetDirectoryName(outputPathWithoutExtension)); string outputPath = CreateImage(item, itemsWithImages, outputPathWithoutExtension, imageType, 0); @@ -119,51 +126,62 @@ namespace Emby.Server.Implementations.Images return ItemUpdateType.ImageUpdate; } - protected abstract List<BaseItem> GetItemsWithImages(BaseItem item); + protected abstract IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item); - protected string CreateThumbCollage(BaseItem primaryItem, List<BaseItem> items, string outputPath) + protected string CreateThumbCollage(BaseItem primaryItem, IEnumerable<BaseItem> items, string outputPath) { return CreateCollage(primaryItem, items, outputPath, 640, 360); } protected virtual IEnumerable<string> GetStripCollageImagePaths(BaseItem primaryItem, IEnumerable<BaseItem> items) { + var useBackdrop = primaryItem is CollectionFolder; return items .Select(i => { - var image = i.GetImageInfo(ImageType.Primary, 0); + // 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) { return image.Path; } - image = i.GetImageInfo(ImageType.Thumb, 0); + image = i.GetImageInfo(ImageType.Thumb, 0); if (image != null && image.IsLocalFile) { return image.Path; } + return null; }) .Where(i => !string.IsNullOrEmpty(i)); } - protected string CreatePosterCollage(BaseItem primaryItem, List<BaseItem> items, string outputPath) + protected string CreatePosterCollage(BaseItem primaryItem, IEnumerable<BaseItem> items, string outputPath) { return CreateCollage(primaryItem, items, outputPath, 400, 600); } - protected string CreateSquareCollage(BaseItem primaryItem, List<BaseItem> items, string outputPath) + protected string CreateSquareCollage(BaseItem primaryItem, IEnumerable<BaseItem> items, string outputPath) { return CreateCollage(primaryItem, items, outputPath, 600, 600); } - protected string CreateThumbCollage(BaseItem primaryItem, List<BaseItem> items, string outputPath, int width, int height) + protected string CreateThumbCollage(BaseItem primaryItem, IEnumerable<BaseItem> items, string outputPath, int width, int height) { return CreateCollage(primaryItem, items, outputPath, width, height); } - private string CreateCollage(BaseItem primaryItem, List<BaseItem> items, string outputPath, int width, int height) + private string CreateCollage(BaseItem primaryItem, IEnumerable<BaseItem> items, string outputPath, int width, int height) { Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); @@ -175,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; } @@ -185,14 +203,13 @@ namespace Emby.Server.Implementations.Images return null; } - ImageProcessor.CreateImageCollage(options); + ImageProcessor.CreateImageCollage(options, primaryItem.Name); return outputPath; } - public string Name => "Dynamic Image Provider"; - - protected virtual string CreateImage(BaseItem item, - List<BaseItem> itemsWithImages, + protected virtual string CreateImage( + BaseItem item, + IReadOnlyCollection<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) @@ -211,36 +228,35 @@ namespace Emby.Server.Implementations.Images if (imageType == ImageType.Primary) { - if (item is UserView) - { - return CreateSquareCollage(item, itemsWithImages, outputPath); - } - if (item is Playlist || item is MusicGenre || item is Genre || item is PhotoAlbum) + if (item is UserView + || item is Playlist + || item is MusicGenre + || item is Genre + || item is PhotoAlbum + || item is MusicArtist) { return CreateSquareCollage(item, itemsWithImages, outputPath); } + return CreatePosterCollage(item, itemsWithImages, outputPath); } - throw new ArgumentException("Unexpected image type"); + throw new ArgumentException("Unexpected image type", nameof(imageType)); } - protected virtual int MaxImageAgeDays => 7; - - public bool HasChanged(BaseItem item, IDirectoryService directoryServicee) + public bool HasChanged(BaseItem item, IDirectoryService directoryService) { if (!Supports(item)) { return false; } - var supportedImages = GetSupportedImages(item); - - if (supportedImages.Contains(ImageType.Primary) && HasChanged(item, ImageType.Primary)) + if (SupportedImages.Contains(ImageType.Primary) && HasChanged(item, ImageType.Primary)) { return true; } - if (supportedImages.Contains(ImageType.Thumb) && HasChanged(item, ImageType.Thumb)) + + if (SupportedImages.Contains(ImageType.Thumb) && HasChanged(item, ImageType.Thumb)) { return true; } @@ -276,16 +292,10 @@ namespace Emby.Server.Implementations.Images protected virtual bool HasChangedByDate(BaseItem item, ItemImageInfo image) { var age = DateTime.UtcNow - image.DateModified; - if (age.TotalDays <= MaxImageAgeDays) - { - return false; - } - return true; + return age.TotalDays > MaxImageAgeDays; } - public int Order => 0; - - protected string CreateSingleImage(List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType) + protected string CreateSingleImage(IEnumerable<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType) { var image = itemsWithImages .Where(i => i.HasImage(imageType) && i.GetImageInfo(imageType, 0).IsLocalFile && Path.HasExtension(i.GetImagePath(imageType))) diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs new file mode 100644 index 000000000..1c69056d2 --- /dev/null +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -0,0 +1,67 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Emby.Server.Implementations.Images +{ + public abstract class BaseFolderImageProvider<T> : BaseDynamicImageProvider<T> + where T : Folder, new() + { + private readonly ILibraryManager _libraryManager; + + public BaseFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Parent = item, + DtoOptions = new DtoOptions(true), + ImageTypes = new ImageType[] { ImageType.Primary }, + OrderBy = new (string, SortOrder)[] + { + (ItemSortBy.IsFolder, SortOrder.Ascending), + (ItemSortBy.SortName, SortOrder.Ascending) + }, + Limit = 1 + }); + } + + protected override string CreateImage(BaseItem item, IReadOnlyCollection<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) + { + return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); + } + + protected override bool Supports(BaseItem item) + { + return item is T; + } + + protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image) + { + if (item is MusicAlbum) + { + return false; + } + + return base.HasChangedByDate(item, image); + } + } +} diff --git a/Emby.Server.Implementations/UserViews/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index ce6c2cd87..0229fbae7 100644 --- a/Emby.Server.Implementations/UserViews/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -1,8 +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 Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; @@ -12,7 +15,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; -namespace Emby.Server.Implementations.UserViews +namespace Emby.Server.Implementations.Images { public class CollectionFolderImageProvider : BaseDynamicImageProvider<CollectionFolder> { @@ -20,34 +23,34 @@ namespace Emby.Server.Implementations.UserViews { } - protected override List<BaseItem> GetItemsWithImages(BaseItem item) + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) { var view = (CollectionFolder)item; var viewType = view.CollectionType; string[] includeItemTypes; - if (string.Equals(viewType, CollectionType.Movies)) + if (string.Equals(viewType, CollectionType.Movies, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Movie" }; } - else if (string.Equals(viewType, CollectionType.TvShows)) + else if (string.Equals(viewType, CollectionType.TvShows, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Series" }; } - else if (string.Equals(viewType, CollectionType.Music)) + else if (string.Equals(viewType, CollectionType.Music, StringComparison.Ordinal)) { includeItemTypes = new string[] { "MusicAlbum" }; } - else if (string.Equals(viewType, CollectionType.Books)) + else if (string.Equals(viewType, CollectionType.Books, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Book", "AudioBook" }; } - else if (string.Equals(viewType, CollectionType.BoxSets)) + else if (string.Equals(viewType, CollectionType.BoxSets, StringComparison.Ordinal)) { includeItemTypes = new string[] { "BoxSet" }; } - else if (string.Equals(viewType, CollectionType.HomeVideos) || string.Equals(viewType, CollectionType.Photos)) + else if (string.Equals(viewType, CollectionType.HomeVideos, StringComparison.Ordinal) || string.Equals(viewType, CollectionType.Photos, StringComparison.Ordinal)) { includeItemTypes = new string[] { "Video", "Photo" }; } @@ -56,7 +59,7 @@ namespace Emby.Server.Implementations.UserViews includeItemTypes = new string[] { "Video", "Audio", "Photo", "Movie", "Series" }; } - var recursive = !new[] { CollectionType.Playlists }.Contains(view.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase); + var recursive = !string.Equals(CollectionType.Playlists, viewType, StringComparison.OrdinalIgnoreCase); return view.GetItemList(new InternalItemsQuery { @@ -70,8 +73,7 @@ namespace Emby.Server.Implementations.UserViews new ValueTuple<string, SortOrder>(ItemSortBy.Random, SortOrder.Ascending) }, IncludeItemTypes = includeItemTypes - - }).ToList(); + }); } protected override bool Supports(BaseItem item) @@ -79,7 +81,7 @@ namespace Emby.Server.Implementations.UserViews return item is CollectionFolder; } - protected override string CreateImage(BaseItem item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) + protected override string CreateImage(BaseItem item, IReadOnlyCollection<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) { var outputPath = Path.ChangeExtension(outputPathWithoutExtension, ".png"); diff --git a/Emby.Server.Implementations/UserViews/DynamicImageProvider.cs b/Emby.Server.Implementations/Images/DynamicImageProvider.cs index 4ec68e550..900b3fd9c 100644 --- a/Emby.Server.Implementations/UserViews/DynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/DynamicImageProvider.cs @@ -1,8 +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; @@ -14,21 +17,19 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -namespace Emby.Server.Implementations.UserViews +namespace Emby.Server.Implementations.Images { public class DynamicImageProvider : BaseDynamicImageProvider<UserView> { private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - public DynamicImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, IUserManager userManager, ILibraryManager libraryManager) + public DynamicImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, IUserManager userManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) { _userManager = userManager; - _libraryManager = libraryManager; } - protected override List<BaseItem> GetItemsWithImages(BaseItem item) + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) { var view = (UserView)item; @@ -46,8 +47,7 @@ namespace Emby.Server.Implementations.UserViews var items = result.Select(i => { - var episode = i as Episode; - if (episode != null) + if (i is Episode episode) { var series = episode.Series; if (series != null) @@ -58,8 +58,7 @@ namespace Emby.Server.Implementations.UserViews return episode; } - var season = i as Season; - if (season != null) + if (i is Season season) { var series = season.Series; if (series != null) @@ -70,8 +69,7 @@ namespace Emby.Server.Implementations.UserViews return season; } - var audio = i as Audio; - if (audio != null) + if (i is Audio audio) { var album = audio.AlbumEntity; if (album != null && album.HasImage(ImageType.Primary)) @@ -81,7 +79,6 @@ namespace Emby.Server.Implementations.UserViews } return i; - }).GroupBy(x => x.Id) .Select(x => x.First()); @@ -89,20 +86,17 @@ namespace Emby.Server.Implementations.UserViews { return items .Where(i => i.HasImage(ImageType.Primary) || i.HasImage(ImageType.Thumb)) - .OrderBy(i => Guid.NewGuid()) .ToList(); } return items .Where(i => i.HasImage(ImageType.Primary)) - .OrderBy(i => Guid.NewGuid()) .ToList(); } protected override bool Supports(BaseItem item) { - var view = item as UserView; - if (view != null) + if (item is UserView view) { return IsUsingCollectionStrip(view); } @@ -122,7 +116,7 @@ namespace Emby.Server.Implementations.UserViews return collectionStripViewTypes.Contains(view.ViewType ?? string.Empty); } - protected override string CreateImage(BaseItem item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) + protected override string CreateImage(BaseItem item, IReadOnlyCollection<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) { if (itemsWithImages.Count == 0) { diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs new file mode 100644 index 000000000..4376bd356 --- /dev/null +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -0,0 +1,37 @@ +#nullable disable + +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class FolderImageProvider : BaseFolderImageProvider<Folder> + { + public FolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + + protected override bool Supports(BaseItem item) + { + if (item is PhotoAlbum || item is MusicAlbum) + { + return false; + } + + if (item is Folder && item.IsTopParent) + { + return false; + } + + return true; + } + } +} diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs new file mode 100644 index 000000000..1f5090f7f --- /dev/null +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -0,0 +1,55 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Emby.Server.Implementations.Images +{ + /// <summary> + /// Class GenreImageProvider. + /// </summary> + public class GenreImageProvider : BaseDynamicImageProvider<Genre> + { + /// <summary> + /// The library manager. + /// </summary> + private readonly ILibraryManager _libraryManager; + + public GenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + /// <summary> + /// Get children objects used to create an genre image. + /// </summary> + /// <param name="item">The genre used to create the image.</param> + /// <returns>Any relevant children objects.</returns> + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Genres = new[] { item.Name }, + IncludeItemTypes = new[] { nameof(Series), nameof(Movie) }, + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, + Limit = 4, + Recursive = true, + ImageTypes = new[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false) + }); + } + } +} diff --git a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs new file mode 100644 index 000000000..ce8367363 --- /dev/null +++ b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs @@ -0,0 +1,19 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class MusicAlbumImageProvider : BaseFolderImageProvider<MusicAlbum> + { + public MusicAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + } +} diff --git a/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs new file mode 100644 index 000000000..baf1c9051 --- /dev/null +++ b/Emby.Server.Implementations/Images/MusicGenreImageProvider.cs @@ -0,0 +1,59 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; + +namespace Emby.Server.Implementations.Images +{ + /// <summary> + /// Class MusicGenreImageProvider. + /// </summary> + public class MusicGenreImageProvider : BaseDynamicImageProvider<MusicGenre> + { + /// <summary> + /// The library manager. + /// </summary> + private readonly ILibraryManager _libraryManager; + + public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + _libraryManager = libraryManager; + } + + /// <summary> + /// Get children objects used to create an music genre image. + /// </summary> + /// <param name="item">The music genre used to create the image.</param> + /// <returns>Any relevant children objects.</returns> + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) + { + return _libraryManager.GetItemList(new InternalItemsQuery + { + Genres = new[] { item.Name }, + IncludeItemTypes = new[] + { + nameof(MusicAlbum), + nameof(MusicVideo), + nameof(Audio) + }, + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, + Limit = 4, + Recursive = true, + ImageTypes = new[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false) + }); + } + } +} diff --git a/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs b/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs new file mode 100644 index 000000000..1ddb4c757 --- /dev/null +++ b/Emby.Server.Implementations/Images/PhotoAlbumImageProvider.cs @@ -0,0 +1,19 @@ +#pragma warning disable CS1591 + +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class PhotoAlbumImageProvider : BaseFolderImageProvider<PhotoAlbum> + { + public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) + : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) + { + } + } +} diff --git a/Emby.Server.Implementations/Images/PlaylistImageProvider.cs b/Emby.Server.Implementations/Images/PlaylistImageProvider.cs new file mode 100644 index 000000000..b8f0f0d65 --- /dev/null +++ b/Emby.Server.Implementations/Images/PlaylistImageProvider.cs @@ -0,0 +1,66 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.Images +{ + public class PlaylistImageProvider : BaseDynamicImageProvider<Playlist> + { + public PlaylistImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor) + { + } + + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) + { + var playlist = (Playlist)item; + + return playlist.GetManageableItems() + .Select(i => + { + var subItem = i.Item2; + + if (subItem is Episode episode) + { + var series = episode.Series; + if (series != null && series.HasImage(ImageType.Primary)) + { + return series; + } + } + + if (subItem.HasImage(ImageType.Primary)) + { + return subItem; + } + + var parent = subItem.GetOwner() ?? subItem.GetParent(); + + if (parent != null && parent.HasImage(ImageType.Primary)) + { + if (parent is MusicAlbum) + { + return parent; + } + } + + return null; + }) + .Where(i => i != null) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .ToList(); + } + } +} diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs index c644d13ea..bc5b4499f 100644 --- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs @@ -1,126 +1,62 @@ using System; using System.IO; -using System.Linq; +using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.IO; namespace Emby.Server.Implementations.Library { /// <summary> - /// Provides the core resolver ignore rules + /// Provides the core resolver ignore rules. /// </summary> public class CoreResolutionIgnoreRule : IResolverIgnoreRule { private readonly ILibraryManager _libraryManager; - - private bool _ignoreDotPrefix; + private readonly IServerApplicationPaths _serverApplicationPaths; /// <summary> - /// Any folder named in this list will be ignored - can be added to at runtime for extensibility + /// Initializes a new instance of the <see cref="CoreResolutionIgnoreRule"/> class. /// </summary> - public static readonly string[] IgnoreFolders = - { - "metadata", - "ps3_update", - "ps3_vprm", - "extrafanart", - "extrathumbs", - ".actors", - ".wd_tv", - - // Synology - "@eaDir", - "eaDir", - "#recycle", - - // Qnap - "@Recycle", - ".@__thumb", - "$RECYCLE.BIN", - "System Volume Information", - ".grab", - - // macos - ".AppleDouble" - - }; - - public CoreResolutionIgnoreRule(ILibraryManager libraryManager) + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverApplicationPaths">The server application paths.</param> + public CoreResolutionIgnoreRule(ILibraryManager libraryManager, IServerApplicationPaths serverApplicationPaths) { _libraryManager = libraryManager; - - _ignoreDotPrefix = Environment.OSVersion.Platform != PlatformID.Win32NT; + _serverApplicationPaths = serverApplicationPaths; } - /// <summary> - /// Shoulds the ignore. - /// </summary> - /// <param name="fileInfo">The file information.</param> - /// <param name="parent">The parent.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + /// <inheritdoc /> public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) { + // Don't ignore application folders + if (fileInfo.FullName.Contains(_serverApplicationPaths.RootFolderPath, StringComparison.InvariantCulture)) + { + return false; + } + // Don't ignore top level folders if (fileInfo.IsDirectory && parent is AggregateFolder) { return false; } - var filename = fileInfo.Name; - var path = fileInfo.FullName; - - // Handle mac .DS_Store - // https://github.com/MediaBrowser/MediaBrowser/issues/427 - if (_ignoreDotPrefix) + if (IgnorePatterns.ShouldIgnore(fileInfo.FullName)) { - if (filename.IndexOf('.') == 0) - { - return true; - } + return true; } - // Ignore hidden files and folders - //if (fileInfo.IsHidden) - //{ - // if (parent == null) - // { - // var parentFolderName = Path.GetFileName(_fileSystem.GetDirectoryName(path)); - - // if (string.Equals(parentFolderName, BaseItem.ThemeSongsFolderName, StringComparison.OrdinalIgnoreCase)) - // { - // return false; - // } - // if (string.Equals(parentFolderName, BaseItem.ThemeVideosFolderName, StringComparison.OrdinalIgnoreCase)) - // { - // return false; - // } - // } - - // // Sometimes these are marked hidden - // if (_fileSystem.IsRootPath(path)) - // { - // return false; - // } - - // return true; - //} + var filename = fileInfo.Name; if (fileInfo.IsDirectory) { - // Ignore any folders in our list - if (IgnoreFolders.Contains(filename, StringComparer.OrdinalIgnoreCase)) - { - return true; - } - if (parent != null) { // Ignore trailer folders but allow it at the collection level - if (string.Equals(filename, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase) && - !(parent is AggregateFolder) && !(parent is UserRootFolder)) + if (string.Equals(filename, BaseItem.TrailersFolderName, StringComparison.OrdinalIgnoreCase) + && !(parent is AggregateFolder) + && !(parent is UserRootFolder)) { return true; } @@ -141,22 +77,12 @@ namespace Emby.Server.Implementations.Library if (parent != null) { // Don't resolve these into audio files - if (string.Equals(Path.GetFileNameWithoutExtension(filename), BaseItem.ThemeSongFilename) && _libraryManager.IsAudioFile(filename)) + if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal) + && _libraryManager.IsAudioFile(filename)) { return true; } } - - // Ignore samples - var sampleFilename = " " + filename.Replace(".", " ", StringComparison.OrdinalIgnoreCase) - .Replace("-", " ", StringComparison.OrdinalIgnoreCase) - .Replace("_", " ", StringComparison.OrdinalIgnoreCase) - .Replace("!", " ", StringComparison.OrdinalIgnoreCase); - - if (sampleFilename.IndexOf(" sample ", StringComparison.OrdinalIgnoreCase) != -1) - { - return true; - } } return false; diff --git a/Emby.Server.Implementations/Library/DefaultAuthenticationProvider.cs b/Emby.Server.Implementations/Library/DefaultAuthenticationProvider.cs deleted file mode 100644 index 4013ac0c8..000000000 --- a/Emby.Server.Implementations/Library/DefaultAuthenticationProvider.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Text; -using System.Threading.Tasks; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Cryptography; - -namespace Emby.Server.Implementations.Library -{ - public class DefaultAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser - { - private readonly ICryptoProvider _cryptographyProvider; - public DefaultAuthenticationProvider(ICryptoProvider crypto) - { - _cryptographyProvider = crypto; - } - - public string Name => "Default"; - - public bool IsEnabled => true; - - public Task<ProviderAuthenticationResult> Authenticate(string username, string password) - { - throw new NotImplementedException(); - } - - public Task<ProviderAuthenticationResult> Authenticate(string username, string password, User resolvedUser) - { - if (resolvedUser == null) - { - throw new Exception("Invalid username or password"); - } - - var success = string.Equals(GetPasswordHash(resolvedUser), GetHashedString(resolvedUser, password), StringComparison.OrdinalIgnoreCase); - - if (!success) - { - throw new Exception("Invalid username or password"); - } - - return Task.FromResult(new ProviderAuthenticationResult - { - Username = username - }); - } - - public Task<bool> HasPassword(User user) - { - var hasConfiguredPassword = !IsPasswordEmpty(user, GetPasswordHash(user)); - return Task.FromResult(hasConfiguredPassword); - } - - private bool IsPasswordEmpty(User user, string passwordHash) - { - return string.Equals(passwordHash, GetEmptyHashedString(user), StringComparison.OrdinalIgnoreCase); - } - - public Task ChangePassword(User user, string newPassword) - { - string newPasswordHash = null; - - if (newPassword != null) - { - newPasswordHash = GetHashedString(user, newPassword); - } - - if (string.IsNullOrWhiteSpace(newPasswordHash)) - { - throw new ArgumentNullException(nameof(newPasswordHash)); - } - - user.Password = newPasswordHash; - - return Task.CompletedTask; - } - - public string GetPasswordHash(User user) - { - return string.IsNullOrEmpty(user.Password) - ? GetEmptyHashedString(user) - : user.Password; - } - - public string GetEmptyHashedString(User user) - { - return GetHashedString(user, string.Empty); - } - - /// <summary> - /// Gets the hashed string. - /// </summary> - public string GetHashedString(User user, string str) - { - var salt = user.Salt; - if (salt != null) - { - // return BCrypt.HashPassword(str, salt); - } - - // legacy - return BitConverter.ToString(_cryptographyProvider.ComputeSHA1(Encoding.UTF8.GetBytes(str))).Replace("-", string.Empty); - } - } -} diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs index 45a33a296..868071a99 100644 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs @@ -1,4 +1,10 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; +using System.Globalization; +using System.IO; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; @@ -8,30 +14,37 @@ namespace Emby.Server.Implementations.Library { public class ExclusiveLiveStream : ILiveStream { + private readonly Func<Task> _closeFn; + + public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func<Task> closeFn) + { + MediaSource = mediaSource; + EnableStreamSharing = false; + _closeFn = closeFn; + ConsumerCount = 1; + UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } + public int ConsumerCount { get; set; } + public string OriginalStreamId { get; set; } public string TunerHostId => null; public bool EnableStreamSharing { get; set; } - public MediaSourceInfo MediaSource { get; set; } - public string UniqueId { get; private set; } + public MediaSourceInfo MediaSource { get; set; } - private Func<Task> _closeFn; + public string UniqueId { get; } - public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func<Task> closeFn) + public Task Close() { - MediaSource = mediaSource; - EnableStreamSharing = false; - _closeFn = closeFn; - ConsumerCount = 1; - UniqueId = Guid.NewGuid().ToString("N"); + return _closeFn(); } - public Task Close() + public Stream GetStream() { - return _closeFn(); + throw new NotSupportedException(); } public Task Open(CancellationToken openCancellationToken) diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs new file mode 100644 index 000000000..5384c04b3 --- /dev/null +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -0,0 +1,123 @@ +using System; +using System.Linq; +using DotNet.Globbing; + +namespace Emby.Server.Implementations.Library +{ + /// <summary> + /// Glob patterns for files to ignore. + /// </summary> + public static class IgnorePatterns + { + /// <summary> + /// Files matching these glob patterns will be ignored. + /// </summary> + private static readonly string[] _patterns = + { + "**/small.jpg", + "**/albumart.jpg", + + // We have neither non-greedy matching or character group repetitions, working around that here. + // https://github.com/dazinator/DotNet.Glob#patterns + // .*/sample\..{1,5} + "**/sample.?", + "**/sample.??", + "**/sample.???", // Matches sample.mkv + "**/sample.????", // Matches sample.webm + "**/sample.?????", + "**/*.sample.?", + "**/*.sample.??", + "**/*.sample.???", + "**/*.sample.????", + "**/*.sample.?????", + "**/sample/*", + + // Directories + "**/metadata/**", + "**/metadata", + "**/ps3_update/**", + "**/ps3_update", + "**/ps3_vprm/**", + "**/ps3_vprm", + "**/extrafanart/**", + "**/extrafanart", + "**/extrathumbs/**", + "**/extrathumbs", + "**/.actors/**", + "**/.actors", + "**/.wd_tv/**", + "**/.wd_tv", + "**/lost+found/**", + "**/lost+found", + + // WMC temp recording directories that will constantly be written to + "**/TempRec/**", + "**/TempRec", + "**/TempSBE/**", + "**/TempSBE", + + // Synology + "**/eaDir/**", + "**/eaDir", + "**/@eaDir/**", + "**/@eaDir", + "**/#recycle/**", + "**/#recycle", + + // Qnap + "**/@Recycle/**", + "**/@Recycle", + "**/.@__thumb/**", + "**/.@__thumb", + "**/$RECYCLE.BIN/**", + "**/$RECYCLE.BIN", + "**/System Volume Information/**", + "**/System Volume Information", + "**/.grab/**", + "**/.grab", + + // Unix hidden files + "**/.*", + + // Mac - if you ever remove the above. + // "**/._*", + // "**/.DS_Store", + + // thumbs.db + "**/thumbs.db", + + // bts sync files + "**/*.bts", + "**/*.sync", + }; + + private static readonly GlobOptions _globOptions = new GlobOptions + { + Evaluation = + { + CaseInsensitive = true + } + }; + + private static readonly Glob[] _globs = _patterns.Select(p => Glob.Parse(p, _globOptions)).ToArray(); + + /// <summary> + /// Returns true if the supplied path should be ignored. + /// </summary> + /// <param name="path">The path to test.</param> + /// <returns>Whether to ignore the path.</returns> + public static bool ShouldIgnore(ReadOnlySpan<char> path) + { + int len = _globs.Length; + for (int i = 0; i < len; i++) + { + if (_globs[i].IsMatch(path)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 3c2272b56..1326f60fe 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1,10 +1,14 @@ +#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.Threading; using System.Threading.Tasks; using Emby.Naming.Audio; @@ -15,78 +19,138 @@ using Emby.Server.Implementations.Library.Resolvers; using Emby.Server.Implementations.Library.Validators; 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; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; 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 SortOrder = MediaBrowser.Model.Entities.SortOrder; +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; namespace Emby.Server.Implementations.Library { /// <summary> - /// Class LibraryManager + /// Class LibraryManager. /// </summary> public class LibraryManager : ILibraryManager { - /// <summary> - /// Gets or sets the postscan tasks. - /// </summary> - /// <value>The postscan tasks.</value> - private ILibraryPostScanTask[] PostscanTasks { get; set; } + private const string ShortcutFileExtension = ".mblink"; - /// <summary> - /// Gets the intro providers. - /// </summary> - /// <value>The intro providers.</value> - private IIntroProvider[] IntroProviders { get; set; } + private readonly ILogger<LibraryManager> _logger; + private readonly IMemoryCache _memoryCache; + private readonly ITaskManager _taskManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataRepository; + private readonly IServerConfigurationManager _configurationManager; + private readonly Lazy<ILibraryMonitor> _libraryMonitorFactory; + private readonly Lazy<IProviderManager> _providerManagerFactory; + private readonly Lazy<IUserViewManager> _userviewManagerFactory; + private readonly IServerApplicationHost _appHost; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly IItemRepository _itemRepository; + private readonly IImageProcessor _imageProcessor; /// <summary> - /// Gets the list of entity resolution ignore rules + /// The _root folder sync lock. /// </summary> - /// <value>The entity resolution ignore rules.</value> - private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } + private readonly object _rootFolderSyncLock = new object(); + private readonly object _userRootFolderSyncLock = new object(); - /// <summary> - /// Gets the list of currently registered entity resolvers - /// </summary> - /// <value>The entity resolvers enumerable.</value> - private IItemResolver[] EntityResolvers { get; set; } - private IMultiItemResolver[] MultiItemResolvers { get; set; } + private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24); + + private NamingOptions _namingOptions; + private string[] _videoFileExtensions; /// <summary> - /// Gets or sets the comparers. + /// The _root folder. /// </summary> - /// <value>The comparers.</value> - private IBaseItemComparer[] Comparers { get; set; } + private volatile AggregateFolder _rootFolder; + private volatile UserRootFolder _userRootFolder; + + private bool _wizardCompleted; /// <summary> - /// Gets the active item repository + /// Initializes a new instance of the <see cref="LibraryManager" /> class. /// </summary> - /// <value>The item repository.</value> - public IItemRepository ItemRepository { get; set; } + /// <param name="appHost">The application host.</param> + /// <param name="logger">The logger.</param> + /// <param name="taskManager">The task manager.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="configurationManager">The configuration manager.</param> + /// <param name="userDataRepository">The user data repository.</param> + /// <param name="libraryMonitorFactory">The library monitor.</param> + /// <param name="fileSystem">The file system.</param> + /// <param name="providerManagerFactory">The provider manager.</param> + /// <param name="userviewManagerFactory">The userview manager.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="itemRepository">The item repository.</param> + /// <param name="imageProcessor">The image processor.</param> + /// <param name="memoryCache">The memory cache.</param> + public LibraryManager( + IServerApplicationHost appHost, + ILogger<LibraryManager> logger, + ITaskManager taskManager, + IUserManager userManager, + IServerConfigurationManager configurationManager, + IUserDataManager userDataRepository, + Lazy<ILibraryMonitor> libraryMonitorFactory, + IFileSystem fileSystem, + Lazy<IProviderManager> providerManagerFactory, + Lazy<IUserViewManager> userviewManagerFactory, + IMediaEncoder mediaEncoder, + IItemRepository itemRepository, + IImageProcessor imageProcessor, + IMemoryCache memoryCache) + { + _appHost = appHost; + _logger = logger; + _taskManager = taskManager; + _userManager = userManager; + _configurationManager = configurationManager; + _userDataRepository = userDataRepository; + _libraryMonitorFactory = libraryMonitorFactory; + _fileSystem = fileSystem; + _providerManagerFactory = providerManagerFactory; + _userviewManagerFactory = userviewManagerFactory; + _mediaEncoder = mediaEncoder; + _itemRepository = itemRepository; + _imageProcessor = imageProcessor; + _memoryCache = memoryCache; + + _configurationManager.ConfigurationUpdated += ConfigurationUpdated; + + RecordConfigurationValues(configurationManager.Configuration); + } /// <summary> /// Occurs when [item added]. @@ -104,85 +168,64 @@ namespace Emby.Server.Implementations.Library public event EventHandler<ItemChangeEventArgs> ItemRemoved; /// <summary> - /// The _logger + /// Gets the root folder. /// </summary> - private readonly ILogger _logger; + /// <value>The root folder.</value> + public AggregateFolder RootFolder + { + get + { + if (_rootFolder == null) + { + lock (_rootFolderSyncLock) + { + _rootFolder ??= CreateRootFolder(); + } + } - /// <summary> - /// The _task manager - /// </summary> - private readonly ITaskManager _taskManager; + return _rootFolder; + } + } - /// <summary> - /// The _user manager - /// </summary> - private readonly IUserManager _userManager; + private ILibraryMonitor LibraryMonitor => _libraryMonitorFactory.Value; + + private IProviderManager ProviderManager => _providerManagerFactory.Value; + + private IUserViewManager UserViewManager => _userviewManagerFactory.Value; /// <summary> - /// The _user data repository + /// Gets or sets the postscan tasks. /// </summary> - private readonly IUserDataManager _userDataRepository; + /// <value>The postscan tasks.</value> + private ILibraryPostScanTask[] PostscanTasks { get; set; } = Array.Empty<ILibraryPostScanTask>(); /// <summary> - /// Gets or sets the configuration manager. + /// Gets or sets the intro providers. /// </summary> - /// <value>The configuration manager.</value> - private IServerConfigurationManager ConfigurationManager { get; set; } - - private readonly Func<ILibraryMonitor> _libraryMonitorFactory; - private readonly Func<IProviderManager> _providerManagerFactory; - private readonly Func<IUserViewManager> _userviewManager; - public bool IsScanRunning { get; private set; } - private IServerApplicationHost _appHost; + /// <value>The intro providers.</value> + private IIntroProvider[] IntroProviders { get; set; } = Array.Empty<IIntroProvider>(); /// <summary> - /// The _library items cache + /// Gets or sets the list of entity resolution ignore rules. /// </summary> - private readonly ConcurrentDictionary<Guid, BaseItem> _libraryItemsCache; + /// <value>The entity resolution ignore rules.</value> + private IResolverIgnoreRule[] EntityResolutionIgnoreRules { get; set; } = Array.Empty<IResolverIgnoreRule>(); + /// <summary> - /// Gets the library items cache. + /// Gets or sets the list of currently registered entity resolvers. /// </summary> - /// <value>The library items cache.</value> - private ConcurrentDictionary<Guid, BaseItem> LibraryItemsCache => _libraryItemsCache; + /// <value>The entity resolvers enumerable.</value> + private IItemResolver[] EntityResolvers { get; set; } = Array.Empty<IItemResolver>(); - private readonly IFileSystem _fileSystem; + private IMultiItemResolver[] MultiItemResolvers { get; set; } = Array.Empty<IMultiItemResolver>(); /// <summary> - /// Initializes a new instance of the <see cref="LibraryManager" /> class. + /// Gets or sets the comparers. /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="taskManager">The task manager.</param> - /// <param name="userManager">The user manager.</param> - /// <param name="configurationManager">The configuration manager.</param> - /// <param name="userDataRepository">The user data repository.</param> - public LibraryManager( - IServerApplicationHost appHost, - ILoggerFactory loggerFactory, - ITaskManager taskManager, - IUserManager userManager, - IServerConfigurationManager configurationManager, - IUserDataManager userDataRepository, - Func<ILibraryMonitor> libraryMonitorFactory, - IFileSystem fileSystem, - Func<IProviderManager> providerManagerFactory, - Func<IUserViewManager> userviewManager) - { - _logger = loggerFactory.CreateLogger(nameof(LibraryManager)); - _taskManager = taskManager; - _userManager = userManager; - ConfigurationManager = configurationManager; - _userDataRepository = userDataRepository; - _libraryMonitorFactory = libraryMonitorFactory; - _fileSystem = fileSystem; - _providerManagerFactory = providerManagerFactory; - _userviewManager = userviewManager; - _appHost = appHost; - _libraryItemsCache = new ConcurrentDictionary<Guid, BaseItem>(); - - ConfigurationManager.ConfigurationUpdated += ConfigurationUpdated; + /// <value>The comparers.</value> + private IBaseItemComparer[] Comparers { get; set; } = Array.Empty<IBaseItemComparer>(); - RecordConfigurationValues(configurationManager.Configuration); - } + public bool IsScanRunning { get; private set; } /// <summary> /// Adds the parts. @@ -191,8 +234,9 @@ namespace Emby.Server.Implementations.Library /// <param name="resolvers">The resolvers.</param> /// <param name="introProviders">The intro providers.</param> /// <param name="itemComparers">The item comparers.</param> - /// <param name="postscanTasks">The postscan tasks.</param> - public void AddParts(IEnumerable<IResolverIgnoreRule> rules, + /// <param name="postscanTasks">The post scan tasks.</param> + public void AddParts( + IEnumerable<IResolverIgnoreRule> rules, IEnumerable<IItemResolver> resolvers, IEnumerable<IIntroProvider> introProviders, IEnumerable<IBaseItemComparer> itemComparers, @@ -203,47 +247,9 @@ namespace Emby.Server.Implementations.Library MultiItemResolvers = EntityResolvers.OfType<IMultiItemResolver>().ToArray(); IntroProviders = introProviders.ToArray(); Comparers = itemComparers.ToArray(); - - PostscanTasks = postscanTasks.OrderBy(i => - { - var hasOrder = i as IHasOrder; - - return hasOrder == null ? 0 : hasOrder.Order; - - }).ToArray(); - } - - /// <summary> - /// The _root folder - /// </summary> - private volatile AggregateFolder _rootFolder; - /// <summary> - /// The _root folder sync lock - /// </summary> - private readonly object _rootFolderSyncLock = new object(); - /// <summary> - /// Gets the root folder. - /// </summary> - /// <value>The root folder.</value> - public AggregateFolder RootFolder - { - get - { - if (_rootFolder == null) - { - lock (_rootFolderSyncLock) - { - if (_rootFolder == null) - { - _rootFolder = CreateRootFolder(); - } - } - } - return _rootFolder; - } + PostscanTasks = postscanTasks.ToArray(); } - private bool _wizardCompleted; /// <summary> /// Records the configuration values. /// </summary> @@ -258,9 +264,9 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="sender">The sender.</param> /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param> - void ConfigurationUpdated(object sender, EventArgs e) + private void ConfigurationUpdated(object sender, EventArgs e) { - var config = ConfigurationManager.Configuration; + var config = _configurationManager.Configuration; var wizardChanged = config.IsStartupWizardCompleted != _wizardCompleted; @@ -278,33 +284,23 @@ namespace Emby.Server.Implementations.Library { throw new ArgumentNullException(nameof(item)); } + if (item is IItemByName) { - if (!(item is MusicArtist)) + if (item is not MusicArtist) { return; } } - - else if (item.IsFolder) - { - //if (!(item is ICollectionFolder) && !(item is UserView) && !(item is Channel) && !(item is AggregateFolder)) - //{ - // if (item.SourceType != SourceType.Library) - // { - // return; - // } - //} - } - else + else if (!item.IsFolder) { - if (!(item is Video) && !(item is LiveTvChannel)) + if (item is not Video && item is not LiveTvChannel) { return; } } - LibraryItemsCache.AddOrUpdate(item.Id, item, delegate { return item; }); + _memoryCache.Set(item.Id, item); } public void DeleteItem(BaseItem item, DeleteOptions options) @@ -345,12 +341,14 @@ namespace Emby.Server.Implementations.Library // channel no longer installed } } + options.DeleteFileLocation = false; } if (item is LiveTvProgram) { - _logger.LogDebug("Deleting item, Type: {0}, Name: {1}, Path: {2}, Id: {3}", + _logger.LogDebug( + "Removing item, Type: {0}, Name: {1}, Path: {2}, Id: {3}", item.GetType().Name, item.Name ?? "Unknown name", item.Path ?? string.Empty, @@ -358,7 +356,8 @@ namespace Emby.Server.Implementations.Library } else { - _logger.LogInformation("Deleting item, Type: {0}, Name: {1}, Path: {2}, Id: {3}", + _logger.LogInformation( + "Removing item, Type: {0}, Name: {1}, Path: {2}, Id: {3}", item.GetType().Name, item.Name ?? "Unknown name", item.Path ?? string.Empty, @@ -371,19 +370,25 @@ namespace Emby.Server.Implementations.Library foreach (var metadataPath in GetMetadataPaths(item, children)) { - _logger.LogDebug("Deleting path {0}", metadataPath); + if (!Directory.Exists(metadataPath)) + { + continue; + } + + _logger.LogDebug( + "Deleting metadata path, Type: {0}, Name: {1}, Path: {2}, Id: {3}", + item.GetType().Name, + item.Name ?? "Unknown name", + metadataPath, + item.Id); try { Directory.Delete(metadataPath, true); } - catch (IOException) - { - - } catch (Exception ex) { - _logger.LogError(ex, "Error deleting {metadataPath}", metadataPath); + _logger.LogError(ex, "Error deleting {MetadataPath}", metadataPath); } } @@ -393,13 +398,19 @@ namespace Emby.Server.Implementations.Library // Add this flag to GetDeletePaths if required in the future var isRequiredForDelete = true; - foreach (var fileSystemInfo in item.GetDeletePaths().ToList()) + foreach (var fileSystemInfo in item.GetDeletePaths()) { - if (File.Exists(fileSystemInfo.FullName)) + if (Directory.Exists(fileSystemInfo.FullName) || File.Exists(fileSystemInfo.FullName)) { try { - _logger.LogDebug("Deleting path {path}", fileSystemInfo.FullName); + _logger.LogInformation( + "Deleting item path, Type: {0}, Name: {1}, Path: {2}, Id: {3}", + item.GetType().Name, + item.Name ?? "Unknown name", + fileSystemInfo.FullName, + item.Id); + if (fileSystemInfo.IsDirectory) { Directory.Delete(fileSystemInfo.FullName, true); @@ -431,13 +442,13 @@ namespace Emby.Server.Implementations.Library item.SetParent(null); - ItemRepository.DeleteItem(item.Id, CancellationToken.None); + _itemRepository.DeleteItem(item.Id); foreach (var child in children) { - ItemRepository.DeleteItem(child.Id, CancellationToken.None); + _itemRepository.DeleteItem(child.Id); } - _libraryItemsCache.TryRemove(item.Id, out BaseItem removed); + _memoryCache.Remove(item.Id); ReportItemRemoved(item, parent); } @@ -497,20 +508,22 @@ namespace Emby.Server.Implementations.Library { throw new ArgumentNullException(nameof(key)); } + if (type == null) { throw new ArgumentNullException(nameof(type)); } - if (key.StartsWith(ConfigurationManager.ApplicationPaths.ProgramDataPath)) + 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) - .TrimStart(new[] { '/', '\\' }) - .Replace("/", "\\"); + key = key.Substring(programDataPath.Length) + .TrimStart('/', '\\') + .Replace('/', '\\'); } - if (forceCaseInsensitive || !ConfigurationManager.Configuration.EnableCaseSensitiveItemIds) + if (forceCaseInsensitive || !_configurationManager.Configuration.EnableCaseSensitiveItemIds) { key = key.ToLowerInvariant(); } @@ -520,13 +533,11 @@ namespace Emby.Server.Implementations.Library return key.GetMD5(); } - public BaseItem ResolvePath(FileSystemMetadata fileInfo, - Folder parent = null) - { - return ResolvePath(fileInfo, new DirectoryService(_logger, _fileSystem), null, parent); - } + public BaseItem ResolvePath(FileSystemMetadata fileInfo, Folder parent = null) + => ResolvePath(fileInfo, new DirectoryService(_fileSystem), null, parent); - private BaseItem ResolvePath(FileSystemMetadata fileInfo, + private BaseItem ResolvePath( + FileSystemMetadata fileInfo, IDirectoryService directoryService, IItemResolver[] resolvers, Folder parent = null, @@ -545,10 +556,9 @@ namespace Emby.Server.Implementations.Library collectionType = GetContentTypeOverride(fullPath, true); } - var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService) + var args = new ItemResolveArgs(_configurationManager.ApplicationPaths, directoryService) { Parent = parent, - Path = fullPath, FileInfo = fileInfo, CollectionType = collectionType, LibraryOptions = libraryOptions @@ -581,7 +591,7 @@ namespace Emby.Server.Implementations.Library { _logger.LogError(ex, "Error in GetFilteredFileSystemEntries isPhysicalRoot: {0} IsVf: {1}", isPhysicalRoot, isVf); - files = new FileSystemMetadata[] { }; + files = Array.Empty<FileSystemMetadata>(); } else { @@ -609,13 +619,7 @@ namespace Emby.Server.Implementations.Library } public bool IgnoreFile(FileSystemMetadata file, BaseItem parent) - { - if (EntityResolutionIgnoreRules.Any(r => r.ShouldIgnore(file, parent))) - { - return true; - } - return false; - } + => EntityResolutionIgnoreRules.Any(r => r.ShouldIgnore(file, parent)); public List<FileSystemMetadata> NormalizeRootPathList(IEnumerable<FileSystemMetadata> paths) { @@ -640,10 +644,10 @@ namespace Emby.Server.Implementations.Library } /// <summary> - /// Determines whether a path should be ignored based on its contents - called after the contents have been read + /// Determines whether a path should be ignored based on its contents - called after the contents have been read. /// </summary> /// <param name="args">The args.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> private static bool ShouldResolvePathContents(ItemResolveArgs args) { // Ignore any folders containing a file called .ignore @@ -655,7 +659,8 @@ namespace Emby.Server.Implementations.Library return ResolvePaths(files, directoryService, parent, libraryOptions, collectionType, EntityResolvers); } - public IEnumerable<BaseItem> ResolvePaths(IEnumerable<FileSystemMetadata> files, + public IEnumerable<BaseItem> ResolvePaths( + IEnumerable<FileSystemMetadata> files, IDirectoryService directoryService, Folder parent, LibraryOptions libraryOptions, @@ -679,8 +684,9 @@ 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)); return items; } @@ -690,39 +696,49 @@ namespace Emby.Server.Implementations.Library return ResolveFileList(fileList, directoryService, parent, collectionType, resolvers, libraryOptions); } - private IEnumerable<BaseItem> ResolveFileList(IEnumerable<FileSystemMetadata> fileList, + private IEnumerable<BaseItem> ResolveFileList( + 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> - /// Creates the root media folder + /// Creates the root media folder. /// </summary> /// <returns>AggregateFolder.</returns> - /// <exception cref="InvalidOperationException">Cannot create the root folder until plugins have loaded</exception> + /// <exception cref="InvalidOperationException">Cannot create the root folder until plugins have loaded.</exception> public AggregateFolder CreateRootFolder() { - var rootFolderPath = ConfigurationManager.ApplicationPaths.RootFolderPath; + var rootFolderPath = _configurationManager.ApplicationPaths.RootFolderPath; Directory.CreateDirectory(rootFolderPath); - var rootFolder = GetItemById(GetNewItemId(rootFolderPath, typeof(AggregateFolder))) as AggregateFolder ?? ((Folder)ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath))).DeepCopy<Folder, AggregateFolder>(); + var rootFolder = GetItemById(GetNewItemId(rootFolderPath, typeof(AggregateFolder))) as AggregateFolder ?? + ((Folder)ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath))) + .DeepCopy<Folder, AggregateFolder>(); // In case program data folder was moved if (!string.Equals(rootFolder.Path, rootFolderPath, StringComparison.Ordinal)) @@ -732,7 +748,7 @@ namespace Emby.Server.Implementations.Library } // Add in the plug-in folders - var path = Path.Combine(ConfigurationManager.ApplicationPaths.DataPath, "playlists"); + var path = Path.Combine(_configurationManager.ApplicationPaths.DataPath, "playlists"); Directory.CreateDirectory(path); @@ -763,7 +779,7 @@ namespace Emby.Server.Implementations.Library if (folder.ParentId != rootFolder.Id) { folder.ParentId = rootFolder.Id; - folder.UpdateToRepository(ItemUpdateType.MetadataImport, CancellationToken.None); + folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult(); } rootFolder.AddVirtualChild(folder); @@ -773,24 +789,33 @@ namespace Emby.Server.Implementations.Library return rootFolder; } - private volatile UserRootFolder _userRootFolder; - private readonly object _syncLock = new object(); public Folder GetUserRootFolder() { if (_userRootFolder == null) { - lock (_syncLock) + lock (_userRootFolderSyncLock) { if (_userRootFolder == null) { - var userRootPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath; + var userRootPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; + _logger.LogDebug("Creating userRootPath at {path}", userRootPath); Directory.CreateDirectory(userRootPath); - var tmpItem = GetItemById(GetNewItemId(userRootPath, typeof(UserRootFolder))) as UserRootFolder; + var newItemId = GetNewItemId(userRootPath, typeof(UserRootFolder)); + UserRootFolder tmpItem = null; + try + { + tmpItem = GetItemById(newItemId) as UserRootFolder; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating UserRootFolder {path}", newItemId); + } if (tmpItem == null) { + _logger.LogDebug("Creating new userRootFolder with DeepCopy"); tmpItem = ((Folder)ResolvePath(_fileSystem.GetDirectoryInfo(userRootPath))).DeepCopy<Folder, UserRootFolder>(); } @@ -802,6 +827,7 @@ namespace Emby.Server.Implementations.Library } _userRootFolder = tmpItem; + _logger.LogDebug("Setting userRootFolder: {folder}", _userRootFolder); } } } @@ -813,19 +839,16 @@ namespace Emby.Server.Implementations.Library { // If this returns multiple items it could be tricky figuring out which one is correct. // In most cases, the newest one will be and the others obsolete but not yet cleaned up - if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException(nameof(path)); } - //_logger.LogInformation("FindByPath {0}", path); - var query = new InternalItemsQuery { Path = path, IsFolder = isFolder, - OrderBy = new[] { ItemSortBy.DateCreated }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), + OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, Limit = 1, DtoOptions = new DtoOptions(true) }; @@ -835,17 +858,31 @@ namespace Emby.Server.Implementations.Library } /// <summary> - /// Gets a Person + /// Gets the person. /// </summary> /// <param name="name">The name.</param> /// <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 not Person item) + { + item = new Person + { + Name = name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + Path = path + }; + } + + return item; } /// <summary> - /// Gets a Studio + /// Gets the studio. /// </summary> /// <param name="name">The name.</param> /// <returns>Task{Studio}.</returns> @@ -856,21 +893,21 @@ 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> - /// Gets a Genre + /// Gets the genre. /// </summary> /// <param name="name">The name.</param> /// <returns>Task{Genre}.</returns> @@ -880,7 +917,7 @@ namespace Emby.Server.Implementations.Library } /// <summary> - /// Gets the genre. + /// Gets the music genre. /// </summary> /// <param name="name">The name.</param> /// <returns>Task{MusicGenre}.</returns> @@ -890,11 +927,10 @@ namespace Emby.Server.Implementations.Library } /// <summary> - /// Gets a Year + /// Gets the year. /// </summary> /// <param name="value">The value.</param> /// <returns>Task{Year}.</returns> - /// <exception cref="ArgumentOutOfRangeException"></exception> public Year GetYear(int value) { if (value <= 0) @@ -908,7 +944,7 @@ namespace Emby.Server.Implementations.Library } /// <summary> - /// Gets a Genre + /// Gets a Genre. /// </summary> /// <param name="name">The name.</param> /// <returns>Task{Genre}.</returns> @@ -929,10 +965,9 @@ 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>() .OrderBy(i => i.IsAccessedByName ? 1 : 0) .Cast<T>() @@ -944,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, @@ -966,11 +999,10 @@ 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; + var forceCaseInsensitiveId = _configurationManager.Configuration.EnableNormalizedItemByNameIds; return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId); } @@ -984,13 +1016,13 @@ namespace Emby.Server.Implementations.Library public Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress) { // Ensure the location is available. - Directory.CreateDirectory(ConfigurationManager.ApplicationPaths.PeoplePath); + Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath); return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress); } /// <summary> - /// Reloads the root media folder + /// Reloads the root media folder. /// </summary> /// <param name="progress">The progress.</param> /// <param name="cancellationToken">The cancellation token.</param> @@ -1021,7 +1053,7 @@ namespace Emby.Server.Implementations.Library public async Task ValidateMediaLibraryInternal(IProgress<double> progress, CancellationToken cancellationToken) { IsScanRunning = true; - _libraryMonitorFactory().Stop(); + LibraryMonitor.Stop(); try { @@ -1029,27 +1061,32 @@ namespace Emby.Server.Implementations.Library } finally { - _libraryMonitorFactory().Start(); + LibraryMonitor.Start(); IsScanRunning = false; } } private async Task ValidateTopLibraryFolders(CancellationToken cancellationToken) { - var rootChildren = RootFolder.Children.ToList(); - rootChildren = GetUserRootFolder().Children.ToList(); - await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); // Start by just validating the children of the root, but go no further - await RootFolder.ValidateChildren(new SimpleProgress<double>(), cancellationToken, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), recursive: false); + await RootFolder.ValidateChildren( + new SimpleProgress<double>(), + new MetadataRefreshOptions(new DirectoryService(_fileSystem)), + recursive: false, + cancellationToken).ConfigureAwait(false); await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false); - await GetUserRootFolder().ValidateChildren(new SimpleProgress<double>(), cancellationToken, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), recursive: false).ConfigureAwait(false); + await GetUserRootFolder().ValidateChildren( + new SimpleProgress<double>(), + new MetadataRefreshOptions(new DirectoryService(_fileSystem)), + recursive: false, + cancellationToken).ConfigureAwait(false); // Quickly scan CollectionFolders for changes - foreach (var folder in GetUserRootFolder().Children.OfType<Folder>().ToList()) + foreach (var folder in GetUserRootFolder().Children.OfType<Folder>()) { await folder.RefreshMetadata(cancellationToken).ConfigureAwait(false); } @@ -1063,10 +1100,10 @@ namespace Emby.Server.Implementations.Library var innerProgress = new ActionableProgress<double>(); - innerProgress.RegisterAction(pct => progress.Report(pct * .96)); + innerProgress.RegisterAction(pct => progress.Report(pct * 0.96)); - // Now validate the entire media library - await RootFolder.ValidateChildren(innerProgress, cancellationToken, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), recursive: true).ConfigureAwait(false); + // Validate the entire media library + await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken).ConfigureAwait(false); progress.Report(96); @@ -1074,7 +1111,6 @@ namespace Emby.Server.Implementations.Library innerProgress.RegisterAction(pct => progress.Report(96 + (pct * .04))); - // Run post-scan tasks await RunPostScanTasks(innerProgress, cancellationToken).ConfigureAwait(false); progress.Report(100); @@ -1125,7 +1161,7 @@ namespace Emby.Server.Implementations.Library } catch (Exception ex) { - _logger.LogError(ex, "Error running postscan task"); + _logger.LogError(ex, "Error running post-scan task"); } numComplete++; @@ -1134,7 +1170,7 @@ namespace Emby.Server.Implementations.Library progress.Report(percent * 100); } - ItemRepository.UpdateInheritedValues(cancellationToken); + _itemRepository.UpdateInheritedValues(); progress.Report(100); } @@ -1150,13 +1186,14 @@ namespace Emby.Server.Implementations.Library public List<VirtualFolderInfo> GetVirtualFolders(bool includeRefreshState) { + _logger.LogDebug("Getting topLibraryFolders"); var topLibraryFolders = GetUserRootFolder().Children.ToList(); - var refreshQueue = includeRefreshState ? _providerManagerFactory().GetRefreshQueue() : null; + _logger.LogDebug("Getting refreshQueue"); + var refreshQueue = includeRefreshState ? ProviderManager.GetRefreshQueue() : null; - return _fileSystem.GetDirectoryPaths(ConfigurationManager.ApplicationPaths.DefaultUserViewsPath) + return _fileSystem.GetDirectoryPaths(_configurationManager.ApplicationPaths.DefaultUserViewsPath) .Select(dir => GetVirtualFolderInfo(dir, topLibraryFolders, refreshQueue)) - .OrderBy(i => i.Name) .ToList(); } @@ -1191,12 +1228,12 @@ namespace Emby.Server.Implementations.Library if (libraryFolder != null && libraryFolder.HasImage(ImageType.Primary)) { - info.PrimaryImageItemId = libraryFolder.Id.ToString("N"); + info.PrimaryImageItemId = libraryFolder.Id.ToString("N", CultureInfo.InvariantCulture); } if (libraryFolder != null) { - info.ItemId = libraryFolder.Id.ToString("N"); + info.ItemId = libraryFolder.Id.ToString("N", CultureInfo.InvariantCulture); info.LibraryOptions = GetLibraryOptions(libraryFolder); if (refreshQueue != null) @@ -1210,11 +1247,18 @@ 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(i => Path.GetFileNameWithoutExtension(i)) - .FirstOrDefault(i => !string.IsNullOrEmpty(i)); + var files = _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false); + foreach (ReadOnlySpan<char> file in files) + { + if (Enum.TryParse<CollectionTypeOptions>(Path.GetFileNameWithoutExtension(file), true, out var res)) + { + return res; + } + } + + return null; } /// <summary> @@ -1222,15 +1266,15 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="id">The id.</param> /// <returns>BaseItem.</returns> - /// <exception cref="ArgumentNullException">id</exception> + /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception> public BaseItem GetItemById(Guid id) { if (id == Guid.Empty) { - throw new ArgumentException(nameof(id), "Guid can't be empty"); + throw new ArgumentException("Guid can't be empty", nameof(id)); } - if (LibraryItemsCache.TryGetValue(id, out BaseItem item)) + if (_memoryCache.TryGetValue(id, out BaseItem item)) { return item; } @@ -1247,7 +1291,7 @@ namespace Emby.Server.Implementations.Library public List<BaseItem> GetItemList(InternalItemsQuery query, bool allowExternalContent) { - if (query.Recursive && !query.ParentId.Equals(Guid.Empty)) + if (query.Recursive && query.ParentId != Guid.Empty) { var parent = GetItemById(query.ParentId); if (parent != null) @@ -1261,7 +1305,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User, allowExternalContent); } - return ItemRepository.GetItemList(query); + return _itemRepository.GetItemList(query); } public List<BaseItem> GetItemList(InternalItemsQuery query) @@ -1285,7 +1329,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User); } - return ItemRepository.GetCount(query); + return _itemRepository.GetCount(query); } public List<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents) @@ -1300,7 +1344,7 @@ namespace Emby.Server.Implementations.Library } } - return ItemRepository.GetItemList(query); + return _itemRepository.GetItemList(query); } public QueryResult<BaseItem> QueryItems(InternalItemsQuery query) @@ -1312,12 +1356,12 @@ namespace Emby.Server.Implementations.Library if (query.EnableTotalRecordCount) { - return ItemRepository.GetItems(query); + return _itemRepository.GetItems(query); } return new QueryResult<BaseItem> { - Items = ItemRepository.GetItemList(query).ToArray() + Items = _itemRepository.GetItemList(query) }; } @@ -1328,7 +1372,7 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User); } - return ItemRepository.GetItemIdsList(query); + return _itemRepository.GetItemIdsList(query); } public QueryResult<(BaseItem, ItemCounts)> GetStudios(InternalItemsQuery query) @@ -1339,7 +1383,7 @@ namespace Emby.Server.Implementations.Library } SetTopParentOrAncestorIds(query); - return ItemRepository.GetStudios(query); + return _itemRepository.GetStudios(query); } public QueryResult<(BaseItem, ItemCounts)> GetGenres(InternalItemsQuery query) @@ -1350,7 +1394,7 @@ namespace Emby.Server.Implementations.Library } SetTopParentOrAncestorIds(query); - return ItemRepository.GetGenres(query); + return _itemRepository.GetGenres(query); } public QueryResult<(BaseItem, ItemCounts)> GetMusicGenres(InternalItemsQuery query) @@ -1361,7 +1405,7 @@ namespace Emby.Server.Implementations.Library } SetTopParentOrAncestorIds(query); - return ItemRepository.GetMusicGenres(query); + return _itemRepository.GetMusicGenres(query); } public QueryResult<(BaseItem, ItemCounts)> GetAllArtists(InternalItemsQuery query) @@ -1372,7 +1416,7 @@ namespace Emby.Server.Implementations.Library } SetTopParentOrAncestorIds(query); - return ItemRepository.GetAllArtists(query); + return _itemRepository.GetAllArtists(query); } public QueryResult<(BaseItem, ItemCounts)> GetArtists(InternalItemsQuery query) @@ -1383,39 +1427,36 @@ namespace Emby.Server.Implementations.Library } SetTopParentOrAncestorIds(query); - return ItemRepository.GetArtists(query); + return _itemRepository.GetArtists(query); } private void SetTopParentOrAncestorIds(InternalItemsQuery query) { - if (query.AncestorIds.Length == 0) + var ancestorIds = query.AncestorIds; + int len = ancestorIds.Length; + if (len == 0) { return; } - var parents = query.AncestorIds.Select(i => GetItemById(i)).ToList(); - - if (parents.All(i => + var parents = new BaseItem[len]; + for (int i = 0; i < len; i++) { - if (i is ICollectionFolder || i is UserView) + parents[i] = GetItemById(ancestorIds[i]); + if (!(parents[i] is ICollectionFolder || parents[i] is UserView)) { - return true; + return; } + } - //_logger.LogDebug("Query requires ancestor query due to type: " + i.GetType().Name); - return false; + // Optimize by querying against top level views + query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + query.AncestorIds = Array.Empty<Guid>(); - })) + // Prevent searching in all libraries due to empty filter + if (query.TopParentIds.Length == 0) { - // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - query.AncestorIds = Array.Empty<Guid>(); - - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) - { - query.TopParentIds = new[] { Guid.NewGuid() }; - } + query.TopParentIds = new[] { Guid.NewGuid() }; } } @@ -1427,7 +1468,7 @@ namespace Emby.Server.Implementations.Library } SetTopParentOrAncestorIds(query); - return ItemRepository.GetAlbumArtists(query); + return _itemRepository.GetAlbumArtists(query); } public QueryResult<BaseItem> GetItemsResult(InternalItemsQuery query) @@ -1448,30 +1489,18 @@ namespace Emby.Server.Implementations.Library if (query.EnableTotalRecordCount) { - return ItemRepository.GetItems(query); + return _itemRepository.GetItems(query); } - var list = ItemRepository.GetItemList(query); - return new QueryResult<BaseItem> { - Items = list.ToArray() + Items = _itemRepository.GetItemList(query) }; } private void SetTopParentIdsOrAncestors(InternalItemsQuery query, List<BaseItem> parents) { - if (parents.All(i => - { - if (i is ICollectionFolder || i is UserView) - { - return true; - } - - //_logger.LogDebug("Query requires ancestor query due to type: " + i.GetType().Name); - return false; - - })) + if (parents.All(i => i is ICollectionFolder || i is UserView)) { // Optimize by querying against top level views query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); @@ -1501,13 +1530,13 @@ 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) && query.ItemIds.Length == 0) { - var userViews = _userviewManager().GetUserViews(new UserViewQuery + var userViews = UserViewManager.GetUserViews(new UserViewQuery { UserId = user.Id, IncludeHidden = true, @@ -1520,11 +1549,9 @@ namespace Emby.Server.Implementations.Library private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User user) { - var view = item as UserView; - - if (view != null) + if (item is UserView view) { - if (string.Equals(view.ViewType, CollectionType.LiveTv)) + if (string.Equals(view.ViewType, CollectionType.LiveTv, StringComparison.Ordinal)) { return new[] { view.Id }; } @@ -1537,8 +1564,10 @@ namespace Emby.Server.Implementations.Library { return GetTopParentIdsForQuery(displayParent, user); } + return Array.Empty<Guid>(); } + if (!view.ParentId.Equals(Guid.Empty)) { var displayParent = GetItemById(view.ParentId); @@ -1546,11 +1575,13 @@ namespace Emby.Server.Implementations.Library { return GetTopParentIdsForQuery(displayParent, user); } + return Array.Empty<Guid>(); } // Handle grouping - if (user != null && !string.IsNullOrEmpty(view.ViewType) && UserView.IsEligibleForGrouping(view.ViewType) && user.Configuration.GroupedFolders.Length > 0) + if (user != null && !string.IsNullOrEmpty(view.ViewType) && UserView.IsEligibleForGrouping(view.ViewType) + && user.GetPreference(PreferenceKind.GroupedFolders).Length > 0) { return GetUserRootFolder() .GetChildren(user, true) @@ -1559,11 +1590,11 @@ namespace Emby.Server.Implementations.Library .Where(i => user.IsFolderGrouped(i.Id)) .SelectMany(i => GetTopParentIdsForQuery(i, user)); } + return Array.Empty<Guid>(); } - var collectionFolder = item as CollectionFolder; - if (collectionFolder != null) + if (item is CollectionFolder collectionFolder) { return collectionFolder.PhysicalFolderIds; } @@ -1573,6 +1604,7 @@ namespace Emby.Server.Implementations.Library { return new[] { topParent.Id }; } + return Array.Empty<Guid>(); } @@ -1585,7 +1617,6 @@ namespace Emby.Server.Implementations.Library public async Task<IEnumerable<Video>> GetIntros(BaseItem item, User user) { var tasks = IntroProviders - .OrderBy(i => i.GetType().Name.IndexOf("Default", StringComparison.OrdinalIgnoreCase) == -1 ? 0 : 1) .Take(1) .Select(i => GetIntros(i, item, user)); @@ -1728,22 +1759,20 @@ namespace Emby.Server.Implementations.Library return orderedItems ?? items; } - public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ValueTuple<string, SortOrder>> orderByList) + public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ValueTuple<string, SortOrder>> orderBy) { var isFirst = true; IOrderedEnumerable<BaseItem> orderedItems = null; - foreach (var orderBy in orderByList) + foreach (var (name, sortOrder) in orderBy) { - var comparer = GetComparer(orderBy.Item1, user); + var comparer = GetComparer(name, user); if (comparer == null) { continue; } - var sortOrder = orderBy.Item2; - if (isFirst) { orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, comparer) : items.OrderBy(i => i, comparer); @@ -1769,19 +1798,16 @@ namespace Emby.Server.Implementations.Library { var comparer = Comparers.FirstOrDefault(c => string.Equals(name, c.Name, StringComparison.OrdinalIgnoreCase)); - if (comparer != null) + // If it requires a user, create a new one, and assign the user + if (comparer is IUserBaseItemComparer) { - // If it requires a user, create a new one, and assign the user - if (comparer is IUserBaseItemComparer) - { - var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType()); + var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType()); - userComparer.User = user; - userComparer.UserManager = _userManager; - userComparer.UserDataRepository = _userDataRepository; + userComparer.User = user; + userComparer.UserManager = _userManager; + userComparer.UserDataRepository = _userDataRepository; - return userComparer; - } + return userComparer; } return comparer; @@ -1792,7 +1818,6 @@ namespace Emby.Server.Implementations.Library /// </summary> /// <param name="item">The item.</param> /// <param name="parent">The parent item.</param> - /// <returns>Task.</returns> public void CreateItem(BaseItem item, BaseItem parent) { CreateItems(new[] { item }, parent, CancellationToken.None); @@ -1802,11 +1827,11 @@ namespace Emby.Server.Implementations.Library /// Creates the items. /// </summary> /// <param name="items">The items.</param> + /// <param name="parent">The parent item.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - public void CreateItems(IEnumerable<BaseItem> items, BaseItem parent, CancellationToken cancellationToken) + public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken) { - ItemRepository.SaveItems(items, cancellationToken); + _itemRepository.SaveItems(items, cancellationToken); foreach (var item in items) { @@ -1825,11 +1850,13 @@ namespace Emby.Server.Implementations.Library try { - ItemAdded(this, new ItemChangeEventArgs - { - Item = item, - Parent = parent ?? item.GetParent() - }); + ItemAdded( + this, + new ItemChangeEventArgs + { + Item = item, + Parent = parent ?? item.GetParent() + }); } catch (Exception ex) { @@ -1839,34 +1866,119 @@ namespace Emby.Server.Implementations.Library } } - public void UpdateImages(BaseItem item) + private bool ImageNeedsRefresh(ItemImageInfo image) { - ItemRepository.SaveImages(item); + if (image.Path != null && image.IsLocalFile) + { + if (image.Width == 0 || image.Height == 0 || string.IsNullOrEmpty(image.BlurHash)) + { + return true; + } - RegisterItem(item); + try + { + return _fileSystem.GetLastWriteTimeUtc(image.Path) != image.DateModified; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot get file info for {0}", image.Path); + return false; + } + } + + return image.Path != null && !image.IsLocalFile; } - /// <summary> - /// Updates the item. - /// </summary> - public void UpdateItems(IEnumerable<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) + /// <inheritdoc /> + public async Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false) { - foreach (var item in items) + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + var outdated = forceUpdate ? item.ImageInfos.Where(i => i.Path != null).ToArray() : item.ImageInfos.Where(ImageNeedsRefresh).ToArray(); + // Skip image processing if current or live tv source + if (outdated.Length == 0 || item.SourceType != SourceType.Library) + { + RegisterItem(item); + return; + } + + foreach (var img in outdated) { - if (item.IsFileProtocol) + var image = img; + if (!img.IsLocalFile) { - _providerManagerFactory().SaveMetadata(item, updateReason); + try + { + var index = item.GetImageIndex(img); + image = await ConvertImageToLocal(item, img, index).ConfigureAwait(false); + } + catch (ArgumentException) + { + _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 (HttpRequestException ex) + { + _logger.LogWarning(ex, "Cannot fetch image from {ImagePath}. Http status code: {HttpStatus}", img.Path, ex.StatusCode); + continue; + } } - item.DateLastSaved = DateTime.UtcNow; + try + { + ImageDimensions size = _imageProcessor.GetImageDimensions(item, image); + image.Width = size.Width; + image.Height = size.Height; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot get image dimensions for {ImagePath}", image.Path); + image.Width = 0; + image.Height = 0; + continue; + } - RegisterItem(item); + try + { + image.BlurHash = _imageProcessor.GetImageBlurHash(image.Path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot compute blurhash for {ImagePath}", image.Path); + image.BlurHash = string.Empty; + } + + try + { + image.DateModified = _fileSystem.GetLastWriteTimeUtc(image.Path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot update DateModified for {ImagePath}", image.Path); + } } - //var logName = item.LocationType == LocationType.Remote ? item.Name ?? item.Path : item.Path ?? item.Name; - //_logger.LogDebug("Saving {0} to database.", logName); + _itemRepository.SaveImages(item); + RegisterItem(item); + } + + /// <inheritdoc /> + public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) + { + foreach (var item in items) + { + await RunMetadataSavers(item, updateReason).ConfigureAwait(false); + } - ItemRepository.SaveItems(items, cancellationToken); + _itemRepository.SaveItems(items, cancellationToken); if (ItemUpdated != null) { @@ -1880,12 +1992,14 @@ namespace Emby.Server.Implementations.Library try { - ItemUpdated(this, new ItemChangeEventArgs - { - Item = item, - Parent = parent, - UpdateReason = updateReason - }); + ItemUpdated( + this, + new ItemChangeEventArgs + { + Item = item, + Parent = parent, + UpdateReason = updateReason + }); } catch (Exception ex) { @@ -1895,33 +2009,40 @@ namespace Emby.Server.Implementations.Library } } - /// <summary> - /// Updates the item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="updateReason">The update reason.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - public void UpdateItem(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) + => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken); + + public Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) { - UpdateItems(new [] { item }, parent, updateReason, cancellationToken); + if (item.IsFileProtocol) + { + ProviderManager.SaveMetadata(item, updateReason); + } + + item.DateLastSaved = DateTime.UtcNow; + + return UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate); } /// <summary> /// Reports the item removed. /// </summary> /// <param name="item">The item.</param> + /// <param name="parent">The parent item.</param> public void ReportItemRemoved(BaseItem item, BaseItem parent) { if (ItemRemoved != null) { try { - ItemRemoved(this, new ItemChangeEventArgs - { - Item = item, - Parent = parent - }); + ItemRemoved( + this, + new ItemChangeEventArgs + { + Item = item, + Parent = parent + }); } catch (Exception ex) { @@ -1937,7 +2058,7 @@ namespace Emby.Server.Implementations.Library /// <returns>BaseItem.</returns> public BaseItem RetrieveItem(Guid id) { - return ItemRepository.RetrieveItem(id); + return _itemRepository.RetrieveItem(id); } public List<Folder> GetCollectionFolders(BaseItem item) @@ -1959,7 +2080,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) @@ -1984,21 +2105,20 @@ 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(); } public LibraryOptions GetLibraryOptions(BaseItem item) { - var collectionFolder = item as CollectionFolder; - if (collectionFolder == null) + if (item is not 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(); @@ -2047,8 +2167,7 @@ namespace Emby.Server.Implementations.Library public string GetConfiguredContentType(BaseItem item, bool inheritConfiguredPath) { - var collectionFolder = item as ICollectionFolder; - if (collectionFolder != null) + if (item is ICollectionFolder collectionFolder) { return collectionFolder.CollectionType; } @@ -2058,13 +2177,11 @@ namespace Emby.Server.Implementations.Library private string GetContentTypeOverride(string path, bool inherit) { - var nameValuePair = ConfigurationManager.Configuration.ContentTypes.FirstOrDefault(i => _fileSystem.AreEqual(i.Name, path) || (inherit && !string.IsNullOrEmpty(i.Name) && _fileSystem.ContainsSubPath(i.Name, path))); - if (nameValuePair != null) - { - return nameValuePair.Value; - } - - return null; + var nameValuePair = _configurationManager.Configuration.ContentTypes + .FirstOrDefault(i => _fileSystem.AreEqual(i.Name, path) + || (inherit && !string.IsNullOrEmpty(i.Name) + && _fileSystem.ContainsSubPath(i.Name, path))); + return nameValuePair?.Value; } private string GetTopFolderContentType(BaseItem item) @@ -2081,6 +2198,7 @@ namespace Emby.Server.Implementations.Library { break; } + item = parent; } @@ -2091,10 +2209,8 @@ namespace Emby.Server.Implementations.Library .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } - private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromHours(24); - //private readonly TimeSpan _viewRefreshInterval = TimeSpan.FromMinutes(1); - - public UserView GetNamedView(User user, + public UserView GetNamedView( + User user, string name, string viewType, string sortName) @@ -2102,13 +2218,15 @@ namespace Emby.Server.Implementations.Library return GetNamedView(user, name, Guid.Empty, viewType, sortName); } - public UserView GetNamedView(string name, + public UserView GetNamedView( + string name, string viewType, string sortName) { - var path = Path.Combine(ConfigurationManager.ApplicationPaths.InternalMetadataPath, - "views", - _fileSystem.GetValidFilename(viewType)); + var path = Path.Combine( + _configurationManager.ApplicationPaths.InternalMetadataPath, + "views", + _fileSystem.GetValidFilename(viewType)); var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView)); @@ -2137,25 +2255,26 @@ namespace Emby.Server.Implementations.Library if (refresh) { - item.UpdateToRepository(ItemUpdateType.MetadataImport, CancellationToken.None); - _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), RefreshPriority.Normal); + item.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult(); + ProviderManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.Normal); } return item; } - public UserView GetNamedView(User user, + public UserView GetNamedView( + User user, string name, Guid parentId, string viewType, string sortName) { - var parentIdString = parentId.Equals(Guid.Empty) ? null : parentId.ToString("N"); - var idValues = "38_namedview_" + name + user.Id.ToString("N") + (parentIdString ?? string.Empty) + (viewType ?? string.Empty); + var parentIdString = parentId.Equals(Guid.Empty) ? null : parentId.ToString("N", CultureInfo.InvariantCulture); + var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType ?? string.Empty); var id = GetNewItemId(idValues, typeof(UserView)); - var path = Path.Combine(ConfigurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N")); + var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N", CultureInfo.InvariantCulture)); var item = GetItemById(id) as UserView; @@ -2173,11 +2292,10 @@ namespace Emby.Server.Implementations.Library Name = name, ViewType = viewType, ForcedSortName = sortName, - UserId = user.Id + UserId = user.Id, + DisplayParentId = parentId }; - item.DisplayParentId = parentId; - CreateItem(item, null); isNew = true; @@ -2193,20 +2311,23 @@ namespace Emby.Server.Implementations.Library if (refresh) { - _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - // Need to force save to increment DateLastSaved - ForceSave = true - - }, RefreshPriority.Normal); + ProviderManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + // Need to force save to increment DateLastSaved + ForceSave = true + }, + RefreshPriority.Normal); } return item; } - public UserView GetShadowView(BaseItem parent, - string viewType, - string sortName) + public UserView GetShadowView( + BaseItem parent, + string viewType, + string sortName) { if (parent == null) { @@ -2257,18 +2378,21 @@ namespace Emby.Server.Implementations.Library if (refresh) { - _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - // Need to force save to increment DateLastSaved - ForceSave = true - - }, RefreshPriority.Normal); + ProviderManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + // Need to force save to increment DateLastSaved + ForceSave = true + }, + RefreshPriority.Normal); } return item; } - public UserView GetNamedView(string name, + public UserView GetNamedView( + string name, Guid parentId, string viewType, string sortName, @@ -2279,7 +2403,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(name)); } - var parentIdString = parentId.Equals(Guid.Empty) ? null : parentId.ToString("N"); + var parentIdString = parentId.Equals(Guid.Empty) ? null : parentId.ToString("N", CultureInfo.InvariantCulture); var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType ?? string.Empty); if (!string.IsNullOrEmpty(uniqueId)) { @@ -2288,7 +2412,7 @@ namespace Emby.Server.Implementations.Library var id = GetNewItemId(idValues, typeof(UserView)); - var path = Path.Combine(ConfigurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N")); + var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", id.ToString("N", CultureInfo.InvariantCulture)); var item = GetItemById(id) as UserView; @@ -2318,7 +2442,7 @@ namespace Emby.Server.Implementations.Library if (!string.Equals(viewType, item.ViewType, StringComparison.OrdinalIgnoreCase)) { item.ViewType = viewType; - item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); } var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval; @@ -2331,54 +2455,71 @@ namespace Emby.Server.Implementations.Library if (refresh) { - _providerManagerFactory().QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - // Need to force save to increment DateLastSaved - ForceSave = true - }, RefreshPriority.Normal); + ProviderManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + // Need to force save to increment DateLastSaved + ForceSave = true + }, + RefreshPriority.Normal); } return item; } - public void AddExternalSubtitleStreams(List<MediaStream> streams, + public void AddExternalSubtitleStreams( + List<MediaStream> streams, string videoPath, string[] files) { - new SubtitleResolver(BaseItem.LocalizationManager, _fileSystem).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files); + new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files); } - public bool IsVideoFile(string path, LibraryOptions libraryOptions) + public BaseItem GetParentItem(string parentId, Guid? userId) { - var resolver = new VideoResolver(GetNamingOptions()); - return resolver.IsVideoFile(path); + if (string.IsNullOrEmpty(parentId)) + { + return GetParentItem((Guid?)null, userId); + } + + return GetParentItem(new Guid(parentId), userId); } - public bool IsVideoFile(string path) + public BaseItem GetParentItem(Guid? parentId, Guid? userId) { - return IsVideoFile(path, new LibraryOptions()); + if (parentId.HasValue) + { + return GetItemById(parentId.Value); + } + + if (userId.HasValue && userId != Guid.Empty) + { + return GetUserRootFolder(); + } + + return RootFolder; } - public bool IsAudioFile(string path, LibraryOptions libraryOptions) + /// <inheritdoc /> + public bool IsVideoFile(string path) { - var parser = new AudioFileParser(GetNamingOptions()); - return parser.IsAudioFile(path); + return VideoResolver.IsVideoFile(path, GetNamingOptions()); } + /// <inheritdoc /> public bool IsAudioFile(string path) - { - return IsAudioFile(path, new LibraryOptions()); - } + => AudioFileParser.IsAudioFile(path, GetNamingOptions()); + /// <inheritdoc /> public int? GetSeasonNumberFromPath(string path) - { - return new SeasonPathParser(GetNamingOptions()).Parse(path, true, true).SeasonNumber; - } + => SeasonPathParser.Parse(path, true, true).SeasonNumber; + /// <inheritdoc /> 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 @@ -2389,13 +2530,58 @@ 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(); + // 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('.'); + } + } + } - if (episodeInfo == null) + episodeInfo ??= new EpisodeInfo(episode.Path); + + try + { + var libraryOptions = GetLibraryOptions(episode); + if (libraryOptions.EnableEmbeddedEpisodeInfos && string.Equals(episodeInfo.Container, "mp4", StringComparison.OrdinalIgnoreCase)) + { + // Read from metadata + var mediaInfo = _mediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaSource = episode.GetMediaSources(false)[0], + MediaType = DlnaProfileType.Video + }, + CancellationToken.None).GetAwaiter().GetResult(); + if (mediaInfo.ParentIndexNumber > 0) + { + episodeInfo.SeasonNumber = mediaInfo.ParentIndexNumber; + } + + if (mediaInfo.IndexNumber > 0) + { + episodeInfo.EpisodeNumber = mediaInfo.IndexNumber; + } + + if (!string.IsNullOrEmpty(mediaInfo.ShowName)) + { + episodeInfo.SeriesName = mediaInfo.ShowName; + } + } + } + catch (Exception ex) { - episodeInfo = new Naming.TV.EpisodeInfo(); + _logger.LogError(ex, "Error reading the episode informations with ffprobe. Episode: {EpisodeInfo}", episodeInfo.Path); } var changed = false; @@ -2445,16 +2631,18 @@ namespace Emby.Server.Implementations.Library { changed = true; } + episode.IndexNumber = episodeInfo.EpisodeNumber; } 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) @@ -2463,6 +2651,7 @@ namespace Emby.Server.Implementations.Library { changed = true; } + episode.ParentIndexNumber = episodeInfo.SeasonNumber; } } @@ -2475,6 +2664,15 @@ namespace Emby.Server.Implementations.Library { episode.ParentIndexNumber = season.IndexNumber; } + else + { + /* + Anime series don't generally have a season in their file name, however, + tvdb needs a season to correctly get the metadata. + Hence, a null season needs to be filled with something. */ + // FIXME perhaps this would be better for tvdb parser to ask for season 1 if no season is specified + episode.ParentIndexNumber = 1; + } if (episode.ParentIndexNumber.HasValue) { @@ -2485,21 +2683,13 @@ namespace Emby.Server.Implementations.Library return changed; } + /// <inheritdoc /> public NamingOptions GetNamingOptions() { - return GetNamingOptionsInternal(); - } - - private NamingOptions _namingOptions; - private string[] _videoFileExtensions; - private NamingOptions GetNamingOptionsInternal() - { if (_namingOptions == null) { - var options = new NamingOptions(); - - _namingOptions = options; - _videoFileExtensions = _namingOptions.VideoFileExtensions.ToArray(); + _namingOptions = new NamingOptions(); + _videoFileExtensions = _namingOptions.VideoFileExtensions; } return _namingOptions; @@ -2507,14 +2697,12 @@ namespace Emby.Server.Implementations.Library public ItemLookupInfo ParseName(string name) { - var resolver = new VideoResolver(GetNamingOptions()); - - var result = resolver.CleanDateTime(name); - var cleanName = resolver.CleanString(result.Name); + var namingOptions = GetNamingOptions(); + var result = VideoResolver.CleanDateTime(name, namingOptions); return new ItemLookupInfo { - Name = cleanName.Name, + Name = VideoResolver.TryCleanString(result.Name, namingOptions, out var newName) ? newName.ToString() : result.Name, Year = result.Year }; } @@ -2524,19 +2712,17 @@ namespace Emby.Server.Implementations.Library var namingOptions = GetNamingOptions(); var files = owner.IsInMixedFolder ? new List<FileSystemMetadata>() : fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => string.Equals(i.Name, BaseItem.TrailerFolderName, StringComparison.OrdinalIgnoreCase)) + .Where(i => string.Equals(i.Name, BaseItem.TrailersFolderName, StringComparison.OrdinalIgnoreCase)) .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.First().Path, StringComparison.OrdinalIgnoreCase)); + var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); if (currentVideo != null) { - files.AddRange(currentVideo.Extras.Where(i => string.Equals(i.ExtraType, "trailer", StringComparison.OrdinalIgnoreCase)).Select(i => _fileSystem.GetFileInfo(i.Path))); + files.AddRange(currentVideo.Extras.Where(i => i.ExtraType == ExtraType.Trailer).Select(i => _fileSystem.GetFileInfo(i.Path))); } var resolvers = new IItemResolver[] @@ -2549,9 +2735,7 @@ namespace Emby.Server.Implementations.Library .Select(video => { // Try to retrieve it from the db. If we don't find it, use the resolved version - var dbItem = GetItemById(video.Id) as Trailer; - - if (dbItem != null) + if (GetItemById(video.Id) is Trailer dbItem) { video = dbItem; } @@ -2567,26 +2751,22 @@ namespace Emby.Server.Implementations.Library }).OrderBy(i => i.Path); } - private static readonly string[] ExtrasSubfolderNames = new[] { "extras", "specials", "shorts", "scenes", "featurettes", "behind the scenes", "deleted scenes", "interviews" }; - public IEnumerable<Video> FindExtras(BaseItem owner, List<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService) { var namingOptions = GetNamingOptions(); var files = owner.IsInMixedFolder ? new List<FileSystemMetadata>() : fileSystemChildren.Where(i => i.IsDirectory) - .Where(i => ExtrasSubfolderNames.Contains(i.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + .Where(i => BaseItem.AllExtrasTypesFolderNames.ContainsKey(i.Name ?? string.Empty)) .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.First().Path, StringComparison.OrdinalIgnoreCase)); + var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); if (currentVideo != null) { - files.AddRange(currentVideo.Extras.Where(i => !string.Equals(i.ExtraType, "trailer", StringComparison.OrdinalIgnoreCase)).Select(i => _fileSystem.GetFileInfo(i.Path))); + files.AddRange(currentVideo.Extras.Where(i => i.ExtraType != ExtraType.Trailer).Select(i => _fileSystem.GetFileInfo(i.Path))); } return ResolvePaths(files, directoryService, null, new LibraryOptions(), null) @@ -2614,6 +2794,7 @@ namespace Emby.Server.Implementations.Library public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem) { + string newPath; if (ownerItem != null) { var libraryOptions = GetLibraryOptions(ownerItem); @@ -2621,41 +2802,27 @@ 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; } } } } - var metadataPath = ConfigurationManager.Configuration.MetadataPath; - var metadataNetworkPath = ConfigurationManager.Configuration.MetadataNetworkPath; + 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) + 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; } } @@ -2664,45 +2831,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)) + if (path.TryReplaceSubPath(from, to, out var newPath)) { - throw new ArgumentNullException(nameof(path)); - } - if (string.IsNullOrWhiteSpace(from)) - { - 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)) - { - if (to.IndexOf('/') != -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) @@ -2711,35 +2845,12 @@ namespace Emby.Server.Implementations.Library var result = resolver.GetExtraInfo(item.Path); - if (string.Equals(result.ExtraType, "deletedscene", StringComparison.OrdinalIgnoreCase)) - { - item.ExtraType = ExtraType.DeletedScene; - } - else if (string.Equals(result.ExtraType, "behindthescenes", StringComparison.OrdinalIgnoreCase)) - { - item.ExtraType = ExtraType.BehindTheScenes; - } - else if (string.Equals(result.ExtraType, "interview", StringComparison.OrdinalIgnoreCase)) - { - item.ExtraType = ExtraType.Interview; - } - else if (string.Equals(result.ExtraType, "scene", StringComparison.OrdinalIgnoreCase)) - { - item.ExtraType = ExtraType.Scene; - } - else if (string.Equals(result.ExtraType, "sample", StringComparison.OrdinalIgnoreCase)) - { - item.ExtraType = ExtraType.Sample; - } - else - { - item.ExtraType = ExtraType.Clip; - } + item.ExtraType = result.ExtraType; } public List<PersonInfo> GetPeople(InternalPeopleQuery query) { - return ItemRepository.GetPeople(query); + return _itemRepository.GetPeople(query); } public List<PersonInfo> GetPeople(BaseItem item) @@ -2762,7 +2873,7 @@ namespace Emby.Server.Implementations.Library public List<Person> GetPeopleItems(InternalPeopleQuery query) { - return ItemRepository.GetPeopleNames(query).Select(i => + return _itemRepository.GetPeopleNames(query).Select(i => { try { @@ -2773,23 +2884,30 @@ namespace Emby.Server.Implementations.Library _logger.LogError(ex, "Error getting person"); return null; } - }).Where(i => i != null).ToList(); } public List<string> GetPeopleNames(InternalPeopleQuery query) { - return ItemRepository.GetPeopleNames(query); + return _itemRepository.GetPeopleNames(query); } 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); + _itemRepository.UpdatePeople(item.Id, people); + + await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false); } public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex) @@ -2800,30 +2918,32 @@ namespace Emby.Server.Implementations.Library { _logger.LogDebug("ConvertImageToLocal item {0} - image url: {1}", item.Id, url); - await _providerManagerFactory().SaveImage(item, url, image.Type, imageIndex, CancellationToken.None).ConfigureAwait(false); + await ProviderManager.SaveImage(item, url, image.Type, imageIndex, CancellationToken.None).ConfigureAwait(false); - item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); return item.GetImageInfo(image.Type, imageIndex); } - catch (HttpException ex) + catch (HttpRequestException ex) { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) + if (ex.StatusCode.HasValue + && (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden)) { continue; } + throw; } } // Remove this image to prevent it from retrying over and over item.RemoveImage(image); - item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); 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)) { @@ -2832,7 +2952,7 @@ namespace Emby.Server.Implementations.Library name = _fileSystem.GetValidFilename(name); - var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath; + var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; var virtualFolderPath = Path.Combine(rootFolderPath, name); while (Directory.Exists(virtualFolderPath)) @@ -2851,15 +2971,15 @@ namespace Emby.Server.Implementations.Library } } - _libraryMonitorFactory().Stop(); + LibraryMonitor.Stop(); try { 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>()); } @@ -2886,9 +3006,61 @@ namespace Emby.Server.Implementations.Library { // Need to add a delay here or directory watchers may still pick up the changes await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitorFactory().Start(); + LibraryMonitor.Start(); + } + } + } + + 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() @@ -2900,25 +3072,9 @@ namespace Emby.Server.Implementations.Library }); } - private static bool ValidateNetworkPath(string path) + public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath) { - //if (Environment.OSVersion.Platform == PlatformID.Win32NT) - //{ - // // We can't validate protocol-based paths, so just allow them - // if (path.IndexOf("://", StringComparison.OrdinalIgnoreCase) == -1) - // { - // return Directory.Exists(path); - // } - //} - - // Without native support for unc, we cannot validate this when running under mono - return true; - } - - private const string ShortcutFileExtension = ".mblink"; - public void AddMediaPath(string virtualFolderName, MediaPathInfo pathInfo) - { - AddMediaPathInternal(virtualFolderName, pathInfo, true); + AddMediaPathInternal(virtualFolderName, mediaPath, true); } private void AddMediaPathInternal(string virtualFolderName, MediaPathInfo pathInfo, bool saveLibraryOptions) @@ -2932,7 +3088,7 @@ namespace Emby.Server.Implementations.Library if (string.IsNullOrWhiteSpace(path)) { - throw new ArgumentNullException(nameof(path)); + throw new ArgumentException(nameof(path)); } if (!Directory.Exists(path)) @@ -2940,12 +3096,7 @@ namespace Emby.Server.Implementations.Library throw new FileNotFoundException("The path does not exist."); } - if (!string.IsNullOrWhiteSpace(pathInfo.NetworkPath) && !ValidateNetworkPath(pathInfo.NetworkPath)) - { - throw new FileNotFoundException("The network path does not exist."); - } - - var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath; + var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); var shortcutFilename = Path.GetFileNameWithoutExtension(path); @@ -2976,19 +3127,14 @@ namespace Emby.Server.Implementations.Library } } - public void UpdateMediaPath(string virtualFolderName, MediaPathInfo pathInfo) + public void UpdateMediaPath(string virtualFolderName, MediaPathInfo mediaPath) { - if (pathInfo == null) + if (mediaPath == null) { - throw new ArgumentNullException(nameof(pathInfo)); - } - - if (!string.IsNullOrWhiteSpace(pathInfo.NetworkPath) && !ValidateNetworkPath(pathInfo.NetworkPath)) - { - throw new FileNotFoundException("The network path does not exist."); + throw new ArgumentNullException(nameof(mediaPath)); } - var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath; + var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -2998,9 +3144,9 @@ namespace Emby.Server.Implementations.Library var list = libraryOptions.PathInfos.ToList(); foreach (var originalPathInfo in list) { - if (string.Equals(pathInfo.Path, originalPathInfo.Path, StringComparison.Ordinal)) + if (string.Equals(mediaPath.Path, originalPathInfo.Path, StringComparison.Ordinal)) { - originalPathInfo.NetworkPath = pathInfo.NetworkPath; + originalPathInfo.NetworkPath = mediaPath.NetworkPath; break; } } @@ -3023,10 +3169,7 @@ namespace Emby.Server.Implementations.Library { if (!list.Any(i => string.Equals(i.Path, location, StringComparison.Ordinal))) { - list.Add(new MediaPathInfo - { - Path = location - }); + list.Add(new MediaPathInfo(location)); } } @@ -3041,7 +3184,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(name)); } - var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath; + var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; var path = Path.Combine(rootFolderPath, name); @@ -3050,7 +3193,7 @@ namespace Emby.Server.Implementations.Library throw new FileNotFoundException("The media folder does not exist"); } - _libraryMonitorFactory().Stop(); + LibraryMonitor.Stop(); try { @@ -3070,7 +3213,7 @@ namespace Emby.Server.Implementations.Library { // Need to add a delay here or directory watchers may still pick up the changes await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitorFactory().Start(); + LibraryMonitor.Start(); } } } @@ -3084,7 +3227,7 @@ namespace Emby.Server.Implementations.Library var removeList = new List<NameValuePair>(); - foreach (var contentType in ConfigurationManager.Configuration.ContentTypes) + foreach (var contentType in _configurationManager.Configuration.ContentTypes) { if (string.IsNullOrWhiteSpace(contentType.Name)) { @@ -3099,11 +3242,11 @@ namespace Emby.Server.Implementations.Library if (removeList.Count > 0) { - ConfigurationManager.Configuration.ContentTypes = ConfigurationManager.Configuration.ContentTypes + _configurationManager.Configuration.ContentTypes = _configurationManager.Configuration.ContentTypes .Except(removeList) - .ToArray(); + .ToArray(); - ConfigurationManager.SaveConfiguration(); + _configurationManager.SaveConfiguration(); } } @@ -3114,12 +3257,13 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(mediaPath)); } - var rootFolderPath = ConfigurationManager.ApplicationPaths.DefaultUserViewsPath; + var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); if (!Directory.Exists(virtualFolderPath)) { - throw new FileNotFoundException(string.Format("The media collection {0} does not exist", virtualFolderName)); + throw new FileNotFoundException( + string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName)); } var shortcut = _fileSystem.GetFilePaths(virtualFolderPath, true) diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index c3082a78a..83acd8e9f 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -1,18 +1,24 @@ +#nullable disable + +#pragma warning disable CS1591 + 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.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library @@ -21,15 +27,13 @@ namespace Emby.Server.Implementations.Library { private readonly IMediaEncoder _mediaEncoder; private readonly ILogger _logger; + private readonly IApplicationPaths _appPaths; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private IJsonSerializer _json; - private IApplicationPaths _appPaths; - - 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; } @@ -40,15 +44,16 @@ namespace Emby.Server.Implementations.Library var now = DateTime.UtcNow; MediaInfo mediaInfo = null; - var cacheFilePath = string.IsNullOrEmpty(cacheKey) ? null : Path.Combine(_appPaths.CachePath, "mediainfo", cacheKey.GetMD5().ToString("N") + ".json"); + var cacheFilePath = string.IsNullOrEmpty(cacheKey) ? null : Path.Combine(_appPaths.CachePath, "mediainfo", cacheKey.GetMD5().ToString("N", CultureInfo.InvariantCulture) + ".json"); if (!string.IsNullOrEmpty(cacheKey)) { try { - mediaInfo = _json.DeserializeFromFile<MediaInfo>(cacheFilePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); + mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - //_logger.LogDebug("Found cached media info"); + // _logger.LogDebug("Found cached media info"); } catch { @@ -70,20 +75,22 @@ namespace Emby.Server.Implementations.Library mediaSource.AnalyzeDurationMs = 3000; - mediaInfo = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest - { - MediaSource = mediaSource, - MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video, - ExtractChapters = false - - }, cancellationToken).ConfigureAwait(false); + mediaInfo = await _mediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaSource = mediaSource, + MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video, + ExtractChapters = false + }, + cancellationToken).ConfigureAwait(false); if (cacheFilePath != null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - _json.SerializeToFile(mediaInfo, cacheFilePath); + await using FileStream createStream = AsyncFile.OpenWrite(cacheFilePath); + await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); - //_logger.LogDebug("Saved media info to {0}", cacheFilePath); + // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } } @@ -124,7 +131,7 @@ namespace Emby.Server.Implementations.Library mediaSource.RunTimeTicks = null; } - var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio); + var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); if (audioStream == null || audioStream.Index == -1) { @@ -135,7 +142,7 @@ namespace Emby.Server.Implementations.Library mediaSource.DefaultAudioStreamIndex = audioStream.Index; } - var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video); + var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); if (videoStream != null) { if (!videoStream.BitRate.HasValue) @@ -146,17 +153,14 @@ namespace Emby.Server.Implementations.Library { videoStream.BitRate = 30000000; } - else if (width >= 1900) { videoStream.BitRate = 20000000; } - else if (width >= 1200) { videoStream.BitRate = 8000000; } - else if (width >= 700) { videoStream.BitRate = 2000000; diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 24ab8e761..351fced34 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -1,10 +1,19 @@ +#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 Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; @@ -12,32 +21,36 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; 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 { public class MediaSourceManager : IMediaSourceManager, IDisposable { + // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. + private const char LiveStreamIdDelimeter = '_'; + 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; + private readonly IMediaEncoder _mediaEncoder; + private readonly ILocalizationManager _localizationManager; + private readonly IApplicationPaths _appPaths; + + 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; - private readonly ILogger _logger; - private readonly IUserDataManager _userDataManager; - private readonly Func<IMediaEncoder> _mediaEncoder; - private ILocalizationManager _localizationManager; - private IApplicationPaths _appPaths; public MediaSourceManager( IItemRepository itemRepo, @@ -45,17 +58,15 @@ namespace Emby.Server.Implementations.Library ILocalizationManager localizationManager, IUserManager userManager, ILibraryManager libraryManager, - ILoggerFactory loggerFactory, - IJsonSerializer jsonSerializer, + ILogger<MediaSourceManager> logger, IFileSystem fileSystem, IUserDataManager userDataManager, - Func<IMediaEncoder> mediaEncoder) + IMediaEncoder mediaEncoder) { _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; - _logger = loggerFactory.CreateLogger(nameof(MediaSourceManager)); - _jsonSerializer = jsonSerializer; + _logger = logger; _fileSystem = fileSystem; _userDataManager = userDataManager; _mediaEncoder = mediaEncoder; @@ -128,18 +139,34 @@ namespace Emby.Server.Implementations.Library return streams; } - public async Task<List<MediaSourceInfo>> GetPlayackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) + /// <inheritdoc /> + public List<MediaAttachment> GetMediaAttachments(MediaAttachmentQuery query) + { + return _itemRepo.GetMediaAttachments(query); + } + + /// <inheritdoc /> + public List<MediaAttachment> GetMediaAttachments(Guid itemId) + { + return GetMediaAttachments(new MediaAttachmentQuery + { + ItemId = itemId + }); + } + + public async Task<List<MediaSourceInfo>> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) { var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Audio || i.Type == MediaStreamType.Video)) { - await item.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - EnableRemoteContentProbe = true, - MetadataRefreshMode = MediaBrowser.Controller.Providers.MetadataRefreshMode.FullRefresh - - }, cancellationToken).ConfigureAwait(false); + await item.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + EnableRemoteContentProbe = true, + MetadataRefreshMode = MetadataRefreshMode.FullRefresh + }, + cancellationToken).ConfigureAwait(false); mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); } @@ -172,15 +199,17 @@ namespace Emby.Server.Implementations.Library { if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) { - if (!user.Policy.EnableAudioPlaybackTranscoding) - { - source.SupportsTranscoding = false; - } + 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) @@ -189,22 +218,27 @@ namespace Emby.Server.Implementations.Library { return MediaProtocol.Rtsp; } + if (path.StartsWith("Rtmp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Rtmp; } + if (path.StartsWith("Http", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Http; } + if (path.StartsWith("rtp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Rtp; } + if (path.StartsWith("ftp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Ftp; } + if (path.StartsWith("udp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Udp; @@ -269,7 +303,7 @@ namespace Emby.Server.Implementations.Library private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource) { - var prefix = provider.GetType().FullName.GetMD5().ToString("N") + LiveStreamIdDelimeter; + var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimeter; if (!string.IsNullOrEmpty(mediaSource.OpenToken) && !mediaSource.OpenToken.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { @@ -289,7 +323,7 @@ namespace Emby.Server.Implementations.Library return await GetLiveStream(liveStreamId, cancellationToken).ConfigureAwait(false); } - var sources = await GetPlayackMediaSources(item, null, false, enablePathSubstitution, cancellationToken).ConfigureAwait(false); + var sources = await GetPlaybackMediaSources(item, null, false, enablePathSubstitution, cancellationToken).ConfigureAwait(false); return sources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)); } @@ -318,7 +352,7 @@ namespace Emby.Server.Implementations.Library private string[] NormalizeLanguage(string language) { - if (language == null) + if (string.IsNullOrEmpty(language)) { return Array.Empty<string>(); } @@ -334,7 +368,9 @@ namespace Emby.Server.Implementations.Library private void SetDefaultSubtitleStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection) { - if (userData.SubtitleStreamIndex.HasValue && user.Configuration.RememberSubtitleSelections && user.Configuration.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection) + if (userData.SubtitleStreamIndex.HasValue + && user.RememberSubtitleSelections + && user.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection) { var index = userData.SubtitleStreamIndex.Value; // Make sure the saved index is still valid @@ -345,26 +381,25 @@ namespace Emby.Server.Implementations.Library } } - var preferredSubs = string.IsNullOrEmpty(user.Configuration.SubtitleLanguagePreference) - ? Array.Empty<string>() : NormalizeLanguage(user.Configuration.SubtitleLanguagePreference); + var preferredSubs = NormalizeLanguage(user.SubtitleLanguagePreference); var defaultAudioIndex = source.DefaultAudioStreamIndex; var audioLangage = defaultAudioIndex == null ? null : source.MediaStreams.Where(i => i.Type == MediaStreamType.Audio && i.Index == defaultAudioIndex).Select(i => i.Language).FirstOrDefault(); - source.DefaultSubtitleStreamIndex = MediaStreamSelector.GetDefaultSubtitleStreamIndex(source.MediaStreams, + source.DefaultSubtitleStreamIndex = MediaStreamSelector.GetDefaultSubtitleStreamIndex( + source.MediaStreams, preferredSubs, - user.Configuration.SubtitleMode, + user.SubtitleMode, audioLangage); - MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, - user.Configuration.SubtitleMode, audioLangage); + MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLangage); } private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection) { - if (userData.AudioStreamIndex.HasValue && user.Configuration.RememberAudioSelections && allowRememberingSelection) + if (userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection) { var index = userData.AudioStreamIndex.Value; // Make sure the saved index is still valid @@ -375,11 +410,9 @@ namespace Emby.Server.Implementations.Library } } - var preferredAudio = string.IsNullOrEmpty(user.Configuration.AudioLanguagePreference) - ? Array.Empty<string>() - : NormalizeLanguage(user.Configuration.AudioLanguagePreference); + var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); - source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.Configuration.PlayDefaultAudioTrack); + source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); } public void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user) @@ -407,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 => { @@ -417,20 +450,17 @@ namespace Emby.Server.Implementations.Library } return 1; - }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) .ThenByDescending(i => { 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(); } - private readonly Dictionary<string, ILiveStream> _openStreams = new Dictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase); - private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); - public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) { await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -478,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); } @@ -490,9 +520,9 @@ namespace Emby.Server.Implementations.Library } // TODO: @bond Fix - var json = _jsonSerializer.SerializeToString(mediaSource); - _logger.LogInformation("Live stream opened: " + json); - var clone = _jsonSerializer.DeserializeFromString<MediaSourceInfo>(json); + var json = JsonSerializer.SerializeToUtf8Bytes(mediaSource, _jsonOptions); + _logger.LogInformation("Live stream opened: {@MediaSource}", mediaSource); + var clone = JsonSerializer.Deserialize<MediaSourceInfo>(json, _jsonOptions); if (!request.UserId.Equals(Guid.Empty)) { @@ -503,11 +533,7 @@ namespace Emby.Server.Implementations.Library SetDefaultAudioAndSubtitleStreamIndexes(item, clone, user); } - return new Tuple<LiveStreamResponse, IDirectStreamProvider>(new LiveStreamResponse - { - MediaSource = clone - - }, liveStream as IDirectStreamProvider); + return new Tuple<LiveStreamResponse, IDirectStreamProvider>(new LiveStreamResponse(clone), liveStream as IDirectStreamProvider); } private static void AddMediaInfo(MediaSourceInfo mediaSource, bool isAudio) @@ -520,7 +546,7 @@ namespace Emby.Server.Implementations.Library mediaSource.RunTimeTicks = null; } - var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio); + var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); if (audioStream == null || audioStream.Index == -1) { @@ -531,7 +557,7 @@ namespace Emby.Server.Implementations.Library mediaSource.DefaultAudioStreamIndex = audioStream.Index; } - var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video); + var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); if (videoStream != null) { if (!videoStream.BitRate.HasValue) @@ -542,17 +568,14 @@ namespace Emby.Server.Implementations.Library { videoStream.BitRate = 30000000; } - else if (width >= 1900) { videoStream.BitRate = 20000000; } - else if (width >= 1200) { videoStream.BitRate = 8000000; } - else if (width >= 700) { videoStream.BitRate = 2000000; @@ -564,31 +587,6 @@ namespace Emby.Server.Implementations.Library mediaSource.InferTotalBitrate(); } - public async 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; - }); - - return info as IDirectStreamProvider; - } - finally - { - _liveStreamSemaphore.Release(); - } - } - public async Task<LiveStreamResponse> OpenLiveStream(LiveStreamRequest request, CancellationToken cancellationToken) { var result = await OpenLiveStreamInternal(request, cancellationToken).ConfigureAwait(false); @@ -597,19 +595,21 @@ namespace Emby.Server.Implementations.Library public async Task<MediaSourceInfo> GetLiveStreamMediaInfo(string id, CancellationToken cancellationToken) { - var liveStreamInfo = await GetLiveStreamInfo(id, cancellationToken).ConfigureAwait(false); + // TODO probably shouldn't throw here but it is kept for "backwards compatibility" + var liveStreamInfo = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException(); var mediaSource = liveStreamInfo.MediaSource; if (liveStreamInfo is IDirectStreamProvider) { - var info = await _mediaEncoder().GetMediaInfo(new MediaInfoRequest - { - MediaSource = mediaSource, - ExtractChapters = false, - MediaType = DlnaProfileType.Video - - }, cancellationToken).ConfigureAwait(false); + var info = await _mediaEncoder.GetMediaInfo( + new MediaInfoRequest + { + MediaSource = mediaSource, + ExtractChapters = false, + MediaType = DlnaProfileType.Video + }, + cancellationToken).ConfigureAwait(false); mediaSource.MediaStreams = info.MediaStreams; mediaSource.Container = info.Container; @@ -626,15 +626,16 @@ namespace Emby.Server.Implementations.Library var now = DateTime.UtcNow; MediaInfo mediaInfo = null; - var cacheFilePath = string.IsNullOrEmpty(cacheKey) ? null : Path.Combine(_appPaths.CachePath, "mediainfo", cacheKey.GetMD5().ToString("N") + ".json"); + var cacheFilePath = string.IsNullOrEmpty(cacheKey) ? null : Path.Combine(_appPaths.CachePath, "mediainfo", cacheKey.GetMD5().ToString("N", CultureInfo.InvariantCulture) + ".json"); if (!string.IsNullOrEmpty(cacheKey)) { try { - mediaInfo = _jsonSerializer.DeserializeFromFile<MediaInfo>(cacheFilePath); + await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); + mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - //_logger.LogDebug("Found cached media info"); + // _logger.LogDebug("Found cached media info"); } catch (Exception ex) { @@ -656,20 +657,22 @@ namespace Emby.Server.Implementations.Library mediaSource.AnalyzeDurationMs = 3000; } - mediaInfo = await _mediaEncoder().GetMediaInfo(new MediaInfoRequest + mediaInfo = await _mediaEncoder.GetMediaInfo( + new MediaInfoRequest { MediaSource = mediaSource, MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video, ExtractChapters = false - - }, cancellationToken).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(false); 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); + // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } } @@ -735,17 +738,14 @@ namespace Emby.Server.Implementations.Library { videoStream.BitRate = 30000000; } - else if (width >= 1900) { videoStream.BitRate = 20000000; } - else if (width >= 1200) { videoStream.BitRate = 8000000; } - else if (width >= 700) { videoStream.BitRate = 2000000; @@ -765,41 +765,37 @@ namespace Emby.Server.Implementations.Library mediaSource.InferTotalBitrate(true); } - public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken) + public Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(id)) { throw new ArgumentNullException(nameof(id)); } - var info = await GetLiveStreamInfo(id, cancellationToken).ConfigureAwait(false); - return new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info as IDirectStreamProvider); + // TODO probably shouldn't throw here but it is kept for "backwards compatibility" + var info = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException(); + return Task.FromResult(new Tuple<MediaSourceInfo, IDirectStreamProvider>(info.MediaSource, info as IDirectStreamProvider)); } - private async Task<ILiveStream> GetLiveStreamInfo(string id, CancellationToken cancellationToken) + public ILiveStream GetLiveStreamInfo(string id) { 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(); - } - } - finally - { - _liveStreamSemaphore.Release(); + return info; } + + return null; + } + + /// <inheritdoc /> + public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId) + { + return _openStreams.Values.FirstOrDefault(stream => string.Equals(uniqueId, stream?.UniqueId, StringComparison.OrdinalIgnoreCase)); } public async Task<MediaSourceInfo> GetLiveStream(string id, CancellationToken cancellationToken) @@ -827,7 +823,7 @@ namespace Emby.Server.Implementations.Library if (liveStream.ConsumerCount <= 0) { - _openStreams.Remove(id); + _openStreams.TryRemove(id, out _); _logger.LogInformation("Closing live stream {0}", id); @@ -842,24 +838,21 @@ namespace Emby.Server.Implementations.Library } } - // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. - private const char LiveStreamIdDelimeter = '_'; - - private Tuple<IMediaSourceProvider, string> GetProvider(string key) + private (IMediaSourceProvider, string) GetProvider(string key) { if (string.IsNullOrEmpty(key)) { - throw new ArgumentException("key"); + 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"), keys[0], StringComparison.OrdinalIgnoreCase)); + var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase)); - var splitIndex = key.IndexOf(LiveStreamIdDelimeter); + var splitIndex = key.IndexOf(LiveStreamIdDelimeter, StringComparison.Ordinal); var keyId = key.Substring(splitIndex + 1); - return new Tuple<IMediaSourceProvider, string>(provider, keyId); + return (provider, keyId); } /// <summary> @@ -868,9 +861,9 @@ namespace Emby.Server.Implementations.Library public void Dispose() { Dispose(true); + GC.SuppressFinalize(this); } - private readonly object _disposeLock = new object(); /// <summary> /// Releases unmanaged and - optionally - managed resources. /// </summary> @@ -879,15 +872,12 @@ namespace Emby.Server.Implementations.Library { if (dispose) { - lock (_disposeLock) + foreach (var key in _openStreams.Keys.ToList()) { - foreach (var key in _openStreams.Keys.ToList()) - { - var task = CloseLiveStream(key); - - Task.WaitAll(task); - } + CloseLiveStream(key).GetAwaiter().GetResult(); } + + _liveStreamSemaphore.Dispose(); } } } diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index 0a6c8845d..b3837fedb 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -1,7 +1,11 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Linq; -using MediaBrowser.Model.Configuration; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library @@ -33,14 +37,12 @@ namespace Emby.Server.Implementations.Library return null; } - public static int? GetDefaultSubtitleStreamIndex(List<MediaStream> streams, + public static int? GetDefaultSubtitleStreamIndex( + IEnumerable<MediaStream> streams, string[] preferredLanguages, SubtitlePlaybackMode mode, string audioTrackLanguage) { - streams = GetSortedStreams(streams, MediaStreamType.Subtitle, preferredLanguages) - .ToList(); - MediaStream stream = null; if (mode == SubtitlePlaybackMode.None) @@ -48,57 +50,53 @@ namespace Emby.Server.Implementations.Library return null; } + var sortedStreams = streams + .Where(i => i.Type == MediaStreamType.Subtitle) + .OrderByDescending(x => x.IsExternal) + .ThenByDescending(x => x.IsForced && string.Equals(x.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(x => x.IsForced) + .ThenByDescending(x => x.IsDefault) + .ToList(); + if (mode == SubtitlePlaybackMode.Default) { // Prefer embedded metadata over smart logic - - stream = streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) ?? - streams.FirstOrDefault(s => s.IsForced) ?? - streams.FirstOrDefault(s => s.IsDefault); + stream = sortedStreams.FirstOrDefault(s => s.IsExternal || s.IsForced || s.IsDefault); // if the audio language is not understood by the user, load their preferred subs, if there are any if (stream == null && !preferredLanguages.Contains(audioTrackLanguage, StringComparer.OrdinalIgnoreCase)) { - stream = streams.Where(s => !s.IsForced).FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)); + stream = sortedStreams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)); } } else if (mode == SubtitlePlaybackMode.Smart) { - // Prefer smart logic over embedded metadata - // if the audio language is not understood by the user, load their preferred subs, if there are any if (!preferredLanguages.Contains(audioTrackLanguage, StringComparer.OrdinalIgnoreCase)) { - stream = streams.Where(s => !s.IsForced).FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)) ?? + stream = streams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)) ?? streams.FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparer.OrdinalIgnoreCase)); } } else if (mode == SubtitlePlaybackMode.Always) { // always load the most suitable full subtitles - stream = streams.FirstOrDefault(s => !s.IsForced); + stream = sortedStreams.FirstOrDefault(s => !s.IsForced); } else if (mode == SubtitlePlaybackMode.OnlyForced) { // always load the most suitable full subtitles - stream = streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) ?? - streams.FirstOrDefault(s => s.IsForced); + stream = sortedStreams.FirstOrDefault(x => x.IsForced); } // load forced subs if we have found no suitable full subtitles - stream = stream ?? streams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)); - - if (stream != null) - { - return stream.Index; - } - - return null; + stream ??= sortedStreams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)); + return stream?.Index; } 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 => { @@ -113,7 +111,8 @@ namespace Emby.Server.Implementations.Library .ThenBy(i => i.Index); } - public static void SetSubtitleStreamScores(List<MediaStream> streams, + public static void SetSubtitleStreamScores( + List<MediaStream> streams, string[] preferredLanguages, SubtitlePlaybackMode mode, string audioTrackLanguage) diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index 10602fea7..e2f1fb0ad 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -1,13 +1,19 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Linq; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; +using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; namespace Emby.Server.Implementations.Library { @@ -30,9 +36,10 @@ namespace Emby.Server.Implementations.Library return list.Concat(GetInstantMixFromGenres(item.Genres, user, dtoOptions)).ToList(); } - public List<BaseItem> GetInstantMixFromArtist(MusicArtist item, User user, DtoOptions dtoOptions) + /// <inheritdoc /> + public List<BaseItem> GetInstantMixFromArtist(MusicArtist artist, User user, DtoOptions dtoOptions) { - return GetInstantMixFromGenres(item.Genres, user, dtoOptions); + return GetInstantMixFromGenres(artist.Genres, user, dtoOptions); } public List<BaseItem> GetInstantMixFromAlbum(MusicAlbum item, User user, DtoOptions dtoOptions) @@ -45,7 +52,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>() @@ -73,7 +80,6 @@ namespace Emby.Server.Implementations.Library { return Guid.Empty; } - }).Where(i => !i.Equals(Guid.Empty)).ToArray(); return GetInstantMixFromGenreIds(genreIds, user, dtoOptions); @@ -83,53 +89,46 @@ namespace Emby.Server.Implementations.Library { return _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(Audio).Name }, + IncludeItemTypes = new[] { nameof(Audio) }, GenreIds = genreIds.ToArray(), Limit = 200, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.Random, SortOrder.Ascending) }, + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) }, DtoOptions = dtoOptions - }); } 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); } - var playlist = item as Playlist; - if (playlist != null) + if (item is Playlist playlist) { return GetInstantMixFromPlaylist(playlist, user, dtoOptions); } - var album = item as MusicAlbum; - if (album != null) + if (item is MusicAlbum album) { return GetInstantMixFromAlbum(album, user, dtoOptions); } - var artist = item as MusicArtist; - if (artist != null) + if (item is MusicArtist artist) { return GetInstantMixFromArtist(artist, user, dtoOptions); } - var song = item as Audio; - if (song != null) + if (item is Audio song) { return GetInstantMixFromSong(song, user, dtoOptions); } - var folder = item as Folder; - if (folder != null) + if (item is Folder folder) { return GetInstantMixFromFolder(folder, user, dtoOptions); } diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index d3a81f622..d5b855cdf 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -1,45 +1,117 @@ using System; -using System.Text.RegularExpressions; +using System.Diagnostics.CodeAnalysis; +using MediaBrowser.Common.Providers; namespace Emby.Server.Implementations.Library { + /// <summary> + /// Class providing extension methods for working with paths. + /// </summary> public static class PathExtensions { /// <summary> /// Gets the attribute value. /// </summary> /// <param name="str">The STR.</param> - /// <param name="attrib">The attrib.</param> + /// <param name="attribute">The attrib.</param> /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">attrib</exception> - public static string GetAttributeValue(this string str, string attrib) + /// <exception cref="ArgumentException"><paramref name="str" /> or <paramref name="attribute" /> is empty.</exception> + public static string? GetAttributeValue(this string str, string attribute) { - if (string.IsNullOrEmpty(str)) + if (str.Length == 0) { - throw new ArgumentNullException(nameof(str)); + throw new ArgumentException("String can't be empty.", nameof(str)); } - if (string.IsNullOrEmpty(attrib)) + if (attribute.Length == 0) { - throw new ArgumentNullException(nameof(attrib)); + throw new ArgumentException("String can't be empty.", nameof(attribute)); } - string srch = "[" + attrib + "="; + string srch = "[" + attribute + "="; int start = str.IndexOf(srch, StringComparison.OrdinalIgnoreCase); - if (start > -1) + if (start != -1) { start += srch.Length; int end = str.IndexOf(']', start); return str.Substring(start, end - start); } + // for imdbid we also accept pattern matching - if (string.Equals(attrib, "imdbid", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(attribute, "imdbid", StringComparison.OrdinalIgnoreCase)) { - var m = Regex.Match(str, "tt\\d{7}", 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 96d1bff92..ac75e5d3a 100644 --- a/Emby.Server.Implementations/Library/ResolverHelper.cs +++ b/Emby.Server.Implementations/Library/ResolverHelper.cs @@ -9,7 +9,7 @@ using MediaBrowser.Model.IO; namespace Emby.Server.Implementations.Library { /// <summary> - /// Class ResolverHelper + /// Class ResolverHelper. /// </summary> public static class ResolverHelper { @@ -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,49 +88,23 @@ 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 + /// Ensures DateCreated and DateModified have values. /// </summary> /// <param name="fileSystem">The file system.</param> /// <param name="item">The item.</param> /// <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)) { @@ -134,7 +112,7 @@ namespace Emby.Server.Implementations.Library if (childData != null) { - SetDateCreated(item, fileSystem, childData); + SetDateCreated(item, childData); } else { @@ -142,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(); @@ -161,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 e39192d28..fd9747b4b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.IO; @@ -13,24 +17,25 @@ using MediaBrowser.Model.IO; namespace Emby.Server.Implementations.Library.Resolvers.Audio { /// <summary> - /// Class AudioResolver + /// Class AudioResolver. /// </summary> public class AudioResolver : ItemResolver<MediaBrowser.Controller.Entities.Audio.Audio>, IMultiItemResolver { - private readonly ILibraryManager LibraryManager; + private readonly ILibraryManager _libraryManager; public AudioResolver(ILibraryManager libraryManager) { - LibraryManager = libraryManager; + _libraryManager = libraryManager; } /// <summary> /// 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) @@ -48,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) @@ -70,7 +76,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio { // Return audio if the path is a file and has a matching extension - var libraryOptions = args.GetLibraryOptions(); var collectionType = args.GetCollectionType(); var isBooksCollectionType = string.Equals(collectionType, CollectionType.Books, StringComparison.OrdinalIgnoreCase); @@ -83,13 +88,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } var files = args.FileSystemChildren - .Where(i => !LibraryManager.IgnoreFile(i, args.Parent)) + .Where(i => !_libraryManager.IgnoreFile(i, args.Parent)) .ToList(); return FindAudio<AudioBook>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false); } - if (LibraryManager.IsAudioFile(args.Path, libraryOptions)) + if (_libraryManager.IsAudioFile(args.Path)) { var extension = Path.GetExtension(args.Path); @@ -102,7 +107,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio var isMixedCollectionType = string.IsNullOrEmpty(collectionType); // For conflicting extensions, give priority to videos - if (isMixedCollectionType && LibraryManager.IsVideoFile(args.Path, libraryOptions)) + if (isMixedCollectionType && _libraryManager.IsVideoFile(args.Path)) { return null; } @@ -118,7 +123,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio { item = new MediaBrowser.Controller.Entities.Audio.Audio(); } - else if (isBooksCollectionType) { item = new AudioBook(); @@ -178,7 +182,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } } - var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); + var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); var resolver = new AudioBookListResolver(namingOptions); var resolverResult = resolver.Resolve(files).ToList(); @@ -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 { @@ -209,8 +218,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio Name = parseName ? resolvedItem.Name : Path.GetFileNameWithoutExtension(firstMedia.Path), - //AdditionalParts = resolvedItem.Files.Skip(1).Select(i => i.Path).ToArray(), - //LocalAlternateVersions = resolvedItem.AlternateVersions.Select(i => i.Path).ToArray() + // AdditionalParts = resolvedItem.Files.Skip(1).Select(i => i.Path).ToArray(), + // LocalAlternateVersions = resolvedItem.AlternateVersions.Select(i => i.Path).ToArray() }; result.Items.Add(libraryItem); diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index 3ce1da81a..60720dd2f 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -1,11 +1,15 @@ +#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; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -13,15 +17,21 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Resolvers.Audio { /// <summary> - /// Class MusicAlbumResolver + /// Class MusicAlbumResolver. /// </summary> public class MusicAlbumResolver : ItemResolver<MusicAlbum> { - private readonly ILogger _logger; + private readonly ILogger<MusicAlbumResolver> _logger; private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; - public MusicAlbumResolver(ILogger logger, IFileSystem fileSystem, ILibraryManager libraryManager) + /// <summary> + /// Initializes a new instance of the <see cref="MusicAlbumResolver"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="fileSystem">The file system.</param> + /// <param name="libraryManager">The library manager.</param> + public MusicAlbumResolver(ILogger<MusicAlbumResolver> logger, IFileSystem fileSystem, ILibraryManager libraryManager) { _logger = logger; _fileSystem = fileSystem; @@ -32,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. @@ -50,26 +60,38 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return null; } - if (!args.IsDirectory) return null; + if (!args.IsDirectory) + { + return null; + } // Avoid mis-identifying top folders - if (args.HasParent<MusicAlbum>()) return null; - if (args.Parent.IsRoot) return null; + if (args.HasParent<MusicAlbum>()) + { + return null; + } + + if (args.Parent.IsRoot) + { + return null; + } return IsMusicAlbum(args) ? new MusicAlbum() : null; } - /// <summary> - /// Determine if the supplied file data points to a music album + /// Determine if the supplied file data points to a music album. /// </summary> - public bool IsMusicAlbum(string path, IDirectoryService directoryService, LibraryOptions libraryOptions) + /// <param name="path">The path to check.</param> + /// <param name="directoryService">The directory service.</param> + /// <returns><c>true</c> if the provided path points to a music album, <c>false</c> otherwise.</returns> + public bool IsMusicAlbum(string path, IDirectoryService directoryService) { - return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService, _logger, _fileSystem, libraryOptions, _libraryManager); + return ContainsMusic(directoryService.GetFileSystemEntries(path), true, directoryService, _logger, _fileSystem, _libraryManager); } /// <summary> - /// Determine if the supplied resolve args should be considered a music album + /// Determine if the supplied resolve args should be considered a music album. /// </summary> /// <param name="args">The args.</param> /// <returns><c>true</c> if [is music album] [the specified args]; otherwise, <c>false</c>.</returns> @@ -78,83 +100,74 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio // Args points to an album if parent is an Artist folder or it directly contains music if (args.IsDirectory) { - //if (args.Parent is MusicArtist) return true; //saves us from testing children twice - if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService, _logger, _fileSystem, args.GetLibraryOptions(), _libraryManager)) return true; + // if (args.Parent is MusicArtist) return true; // saves us from testing children twice + if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService, _logger, _fileSystem, _libraryManager)) + { + return true; + } } return false; } /// <summary> - /// Determine if the supplied list contains what we should consider music + /// Determine if the supplied list contains what we should consider music. /// </summary> - private bool ContainsMusic(IEnumerable<FileSystemMetadata> list, + private bool ContainsMusic( + IEnumerable<FileSystemMetadata> list, bool allowSubfolders, IDirectoryService directoryService, - ILogger logger, + ILogger<MusicAlbumResolver> logger, IFileSystem fileSystem, - LibraryOptions libraryOptions, 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; - foreach (var fileSystemInfo in list) + var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); + var parser = new AlbumParser(namingOptions); + + 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, libraryOptions, libraryManager); - - if (hasMusic) - { - if (IsMultiDiscFolder(path, libraryOptions)) - { - 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, libraryOptions)) + 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; } return discSubfolderCount > 0; } - - private bool IsMultiDiscFolder(string path, LibraryOptions libraryOptions) - { - var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); - - var parser = new AlbumParser(namingOptions); - var result = parser.ParseMultiPart(path); - - return result.IsMultiPart; - } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index 74e9b8304..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; @@ -11,16 +14,27 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Resolvers.Audio { /// <summary> - /// Class MusicArtistResolver + /// Class MusicArtistResolver. /// </summary> public class MusicArtistResolver : ItemResolver<MusicArtist> { - private readonly ILogger _logger; + private readonly ILogger<MusicAlbumResolver> _logger; private readonly IFileSystem _fileSystem; private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _config; - public MusicArtistResolver(ILogger logger, IFileSystem fileSystem, ILibraryManager libraryManager, IServerConfigurationManager config) + /// <summary> + /// Initializes a new instance of the <see cref="MusicArtistResolver"/> class. + /// </summary> + /// <param name="logger">The logger for the created <see cref="MusicAlbumResolver"/> instances.</param> + /// <param name="fileSystem">The file system.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="config">The configuration manager.</param> + public MusicArtistResolver( + ILogger<MusicAlbumResolver> logger, + IFileSystem fileSystem, + ILibraryManager libraryManager, + IServerConfigurationManager config) { _logger = logger; _fileSystem = fileSystem; @@ -41,7 +55,10 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio /// <returns>MusicArtist.</returns> protected override MusicArtist Resolve(ItemResolveArgs args) { - if (!args.IsDirectory) return null; + if (!args.IsDirectory) + { + return null; + } // Don't allow nested artists if (args.HasParent<MusicArtist>() || args.HasParent<MusicAlbum>()) @@ -64,21 +81,29 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return new MusicArtist(); } - if (_config.Configuration.EnableSimpleArtistDetection) + // Avoid mis-identifying top folders + if (args.Parent.IsRoot) { return null; } - // Avoid mis-identifying top folders - if (args.Parent.IsRoot) return null; - var directoryService = args.DirectoryService; 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, args.GetLibraryOptions())) ? 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 541b13cbe..9ff99fa43 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -1,6 +1,11 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.IO; using System.Linq; +using DiscUtils.Udf; using Emby.Naming.Video; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -10,25 +15,25 @@ using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers { /// <summary> - /// Resolves a Path into a Video or Video subclass + /// Resolves a Path into a Video or Video subclass. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of item to resolve.</typeparam> public abstract class BaseVideoResolver<T> : MediaBrowser.Controller.Resolvers.ItemResolver<T> where T : Video, new() { - protected readonly ILibraryManager LibraryManager; - protected BaseVideoResolver(ILibraryManager libraryManager) { LibraryManager = libraryManager; } + protected ILibraryManager LibraryManager { get; } + /// <summary> /// Resolves the specified args. /// </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); } @@ -40,14 +45,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; @@ -62,7 +65,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) { @@ -77,9 +80,10 @@ namespace Emby.Server.Implementations.Library.Resolvers }; break; } - if (IsBluRayDirectory(child.FullName, filename, args.DirectoryService)) + + if (IsBluRayDirectory(filename)) { - videoInfo = parser.ResolveDirectory(args.Path); + videoInfo = VideoResolver.ResolveDirectory(args.Path, namingOptions); if (videoInfo == null) { @@ -97,7 +101,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) { @@ -127,14 +131,14 @@ 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) { return null; } - if (LibraryManager.IsVideoFile(args.Path, args.GetLibraryOptions()) || videoInfo.IsStub) + if (LibraryManager.IsVideoFile(args.Path) || videoInfo.IsStub) { var path = args.Path; @@ -162,13 +166,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) @@ -190,14 +194,30 @@ 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; } + else + { + // use disc-utils, both DVDs and BDs use UDF filesystem + using (var videoFileStream = File.Open(video.Path, FileMode.Open, FileAccess.Read)) + { + UdfReader udfReader = new UdfReader(videoFileStream); + if (udfReader.DirectoryExists("VIDEO_TS")) + { + video.IsoType = IsoType.Dvd; + } + else if (udfReader.DirectoryExists("BDMV")) + { + video.IsoType = IsoType.BluRay; + } + } + } } } @@ -247,10 +267,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); } @@ -258,6 +275,10 @@ namespace Emby.Server.Implementations.Library.Resolvers /// <summary> /// Determines whether [is DVD directory] [the specified directory name]. /// </summary> + /// <param name="fullPath">The full path of the directory.</param> + /// <param name="directoryName">The name of the directory.</param> + /// <param name="directoryService">The directory service.</param> + /// <returns><c>true</c> if the provided directory is a DVD directory, <c>false</c> otherwise.</returns> protected bool IsDvdDirectory(string fullPath, string directoryName, IDirectoryService directoryService) { if (!string.Equals(directoryName, "video_ts", StringComparison.OrdinalIgnoreCase)) @@ -279,25 +300,13 @@ namespace Emby.Server.Implementations.Library.Resolvers } /// <summary> - /// Determines whether [is blu ray directory] [the specified directory name]. + /// Determines whether [is bluray directory] [the specified directory name]. /// </summary> - protected bool IsBluRayDirectory(string fullPath, string directoryName, IDirectoryService directoryService) + /// <param name="directoryName">The directory name.</param> + /// <returns>Whether the directory is a bluray directory.</returns> + protected bool IsBluRayDirectory(string directoryName) { - if (!string.Equals(directoryName, "bdmv", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return true; - //var blurayExtensions = new[] - //{ - // ".mts", - // ".m2ts", - // ".bdmv", - // ".mpls" - //}; - - //return directoryService.GetFiles(fullPath).Any(i => blurayExtensions.Contains(i.Extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)); + return string.Equals(directoryName, "bdmv", StringComparison.OrdinalIgnoreCase); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index f22554ee5..e685c87f1 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.IO; using System.Linq; @@ -7,25 +11,19 @@ using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers.Books { - /// <summary> - /// - /// </summary> public class BookResolver : MediaBrowser.Controller.Resolvers.ItemResolver<Book> { - private readonly string[] _validExtensions = { ".pdf", ".epub", ".mobi", ".cbr", ".cbz", ".azw3" }; + private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; - /// <summary> - /// - /// </summary> - /// <param name="args"></param> - /// <returns></returns> - protected override Book Resolve(ItemResolveArgs args) + public override Book Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); // Only process items that are in a collection folder containing books if (!string.Equals(collectionType, CollectionType.Books, StringComparison.OrdinalIgnoreCase)) + { return null; + } if (args.IsDirectory) { @@ -47,26 +45,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books return null; } - /// <summary> - /// - /// </summary> - /// <param name="args"></param> - /// <returns></returns> private Book GetBook(ItemResolveArgs args) { var bookFiles = args.FileSystemChildren.Where(f => { - var fileExtension = Path.GetExtension(f.FullName) ?? - string.Empty; + var fileExtension = Path.GetExtension(f.FullName) + ?? string.Empty; - return _validExtensions.Contains(fileExtension, - StringComparer - .OrdinalIgnoreCase); + return _validExtensions.Contains( + fileExtension, + StringComparer.OrdinalIgnoreCase); }).ToList(); // Don't return a Book if there is more (or less) than one document in the directory if (bookFiles.Count != 1) + { return null; + } return new Book { diff --git a/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/FolderResolver.cs index e48b6c967..db7703cd6 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; @@ -5,9 +7,9 @@ using MediaBrowser.Controller.Resolvers; namespace Emby.Server.Implementations.Library.Resolvers { /// <summary> - /// Class FolderResolver + /// Class FolderResolver. /// </summary> - public class FolderResolver : FolderResolver<Folder> + public class FolderResolver : GenericFolderResolver<Folder> { /// <summary> /// Gets the priority. @@ -30,24 +32,4 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } } - - /// <summary> - /// Class FolderResolver - /// </summary> - /// <typeparam name="TItemType">The type of the T item type.</typeparam> - public abstract class FolderResolver<TItemType> : ItemResolver<TItemType> - where TItemType : Folder, new() - { - /// <summary> - /// Sets the initial item values. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="args">The args.</param> - protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args) - { - base.SetInitialItemValues(item, args); - - item.IsRoot = args.Parent == null; - } - } } diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs new file mode 100644 index 000000000..f109a5e9a --- /dev/null +++ b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs @@ -0,0 +1,27 @@ +#nullable disable + +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; + +namespace Emby.Server.Implementations.Library.Resolvers +{ + /// <summary> + /// Class FolderResolver. + /// </summary> + /// <typeparam name="TItemType">The type of the T item type.</typeparam> + public abstract class GenericFolderResolver<TItemType> : ItemResolver<TItemType> + where TItemType : Folder, new() + { + /// <summary> + /// Sets the initial item values. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="args">The args.</param> + protected override void SetInitialItemValues(TItemType item, ItemResolveArgs args) + { + base.SetInitialItemValues(item, args); + + item.IsRoot = args.Parent == null; + } + } +} diff --git a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs index 68d5d8b2d..9599faea4 100644 --- a/Emby.Server.Implementations/Library/Resolvers/VideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; diff --git a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs index a6db40714..3f29ab191 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; @@ -5,13 +7,19 @@ using MediaBrowser.Controller.Resolvers; namespace Emby.Server.Implementations.Library.Resolvers { /// <summary> - /// Class ItemResolver + /// Class ItemResolver. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The type of BaseItem.</typeparam> public abstract class ItemResolver<T> : IItemResolver 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,13 +30,7 @@ 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 + /// Sets initial values on the newly resolved item. /// </summary> /// <param name="item">The item.</param> /// <param name="args">The args.</param> diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs index 922bd4bbb..e7abe1e6d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs @@ -1,17 +1,18 @@ +#nullable disable + using System; using System.IO; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; namespace Emby.Server.Implementations.Library.Resolvers.Movies { /// <summary> - /// Class BoxSetResolver + /// Class BoxSetResolver. /// </summary> - public class BoxSetResolver : FolderResolver<BoxSet> + public class BoxSetResolver : GenericFolderResolver<BoxSet> { /// <summary> /// Resolves the specified args. @@ -63,14 +64,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <param name="item">The item.</param> private static void SetProviderIdFromPath(BaseItem item) { - //we need to only look at the name of this actual item (not parents) + // we need to only look at the name of this actual item (not parents) var justName = Path.GetFileName(item.Path); var id = justName.GetAttributeValue("tmdbid"); if (!string.IsNullOrEmpty(id)) { - item.SetProviderId(MetadataProviders.Tmdb, id); + item.SetProviderId(MetadataProvider.Tmdb, id); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 848563679..f3b6ef0a2 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -1,8 +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; @@ -11,23 +15,46 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.IO; namespace Emby.Server.Implementations.Library.Resolvers.Movies { /// <summary> - /// Class MovieResolver + /// Class MovieResolver. /// </summary> public class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver { + private readonly IImageProcessor _imageProcessor; + + private string[] _validCollectionTypes = new[] + { + CollectionType.Movies, + CollectionType.HomeVideos, + CollectionType.MusicVideos, + CollectionType.Movies, + CollectionType.Photos + }; + + /// <summary> + /// Initializes a new instance of the <see cref="MovieResolver"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="imageProcessor">The image processor.</param> + public MovieResolver(ILibraryManager libraryManager, IImageProcessor imageProcessor) + : base(libraryManager) + { + _imageProcessor = imageProcessor; + } + /// <summary> /// Gets the priority. /// </summary> /// <value>The priority.</value> - public override ResolverPriority Priority => ResolverPriority.Third; + public override ResolverPriority Priority => ResolverPriority.Fourth; - public MultiItemResolverResult ResolveMultiple(Folder parent, + /// <inheritdoc /> + public MultiItemResolverResult ResolveMultiple( + Folder parent, List<FileSystemMetadata> files, string collectionType, IDirectoryService directoryService) @@ -45,7 +72,112 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return result; } - private MultiItemResolverResult ResolveMultipleInternal(Folder parent, + /// <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, string collectionType, IDirectoryService directoryService) @@ -90,7 +222,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return null; } - private MultiItemResolverResult ResolveVideos<T>(Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, IDirectoryService directoryService, bool suppportMultiEditions, string collectionType, bool parseName) + private MultiItemResolverResult ResolveVideos<T>( + Folder parent, + IEnumerable<FileSystemMetadata> fileSystemEntries, + IDirectoryService directoryService, + bool suppportMultiEditions, + string collectionType, + bool parseName) where T : Video, new() { var files = new List<FileSystemMetadata>(); @@ -103,8 +241,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // This is a hack but currently no better way to resolve a sometimes ambiguous situation if (string.IsNullOrEmpty(collectionType)) { - if (string.Equals(child.Name, "tvshow.nfo", StringComparison.OrdinalIgnoreCase) || - string.Equals(child.Name, "season.nfo", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(child.Name, "tvshow.nfo", StringComparison.OrdinalIgnoreCase) + || string.Equals(child.Name, "season.nfo", StringComparison.OrdinalIgnoreCase)) { return null; } @@ -114,20 +252,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies { leftOver.Add(child); } - else if (IsIgnored(child.Name)) - { - - } - else + else if (!IsIgnored(child.Name)) { files.Add(child); } } - 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 { @@ -139,7 +272,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies foreach (var video in resolverResult) { - var firstVideo = video.Files.First(); + var firstVideo = video.Files[0]; var videoItem = new T { @@ -167,17 +300,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies private static bool IsIgnored(string filename) { // Ignore samples - var sampleFilename = " " + filename.Replace(".", " ", StringComparison.OrdinalIgnoreCase) - .Replace("-", " ", StringComparison.OrdinalIgnoreCase) - .Replace("_", " ", StringComparison.OrdinalIgnoreCase) - .Replace("!", " ", StringComparison.OrdinalIgnoreCase); - - if (sampleFilename.IndexOf(" sample ", StringComparison.OrdinalIgnoreCase) != -1) - { - return true; - } + Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase); - return false; + return m.Success; } private bool ContainsFile(List<VideoInfo> result, FileSystemMetadata file) @@ -198,111 +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> @@ -322,7 +342,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies { if (item is Movie || item is MusicVideo) { - //we need to only look at the name of this actual item (not parents) + // We need to only look at the name of this actual item (not parents) var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path) : Path.GetFileName(item.ContainingFolderPath); if (!string.IsNullOrEmpty(justName)) @@ -332,7 +352,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (!string.IsNullOrWhiteSpace(tmdbid)) { - item.SetProviderId(MetadataProviders.Tmdb, tmdbid); + item.SetProviderId(MetadataProvider.Tmdb, tmdbid); } } @@ -343,23 +363,22 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (!string.IsNullOrWhiteSpace(imdbid)) { - item.SetProviderId(MetadataProviders.Imdb, imdbid); + item.SetProviderId(MetadataProvider.Imdb, imdbid); } } } } /// <summary> - /// Finds a movie based on a child file system entries + /// Finds a movie based on a child file system entries. /// </summary> - /// <typeparam name="T"></typeparam> /// <returns>Movie.</returns> private T FindMovie<T>(ItemResolveArgs args, string path, Folder parent, List<FileSystemMetadata> fileSystemEntries, IDirectoryService directoryService, string collectionType, bool parseName) where T : Video, new() { 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>(); @@ -380,7 +399,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies Set3DFormat(movie); return movie; } - if (IsBluRayDirectory(child.FullName, filename, directoryService)) + + if (IsBluRayDirectory(filename)) { var movie = new T { @@ -410,15 +430,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } // TODO: Allow GetMultiDiscMovie in here - const bool supportsMultiVersion = true; + const bool SupportsMultiVersion = true; - var result = ResolveVideos<T>(parent, fileSystemEntries, directoryService, supportsMultiVersion, collectionType, parseName) ?? + var result = ResolveVideos<T>(parent, fileSystemEntries, directoryService, SupportsMultiVersion, collectionType, parseName) ?? new MultiItemResolverResult(); if (result.Items.Count == 1) { var videoPath = result.Items[0].Path; - var hasPhotos = photos.Any(i => !PhotoResolver.IsOwnedByResolvedMedia(LibraryManager, libraryOptions, videoPath, i.Name)); + var hasPhotos = photos.Any(i => !PhotoResolver.IsOwnedByResolvedMedia(LibraryManager, videoPath, i.Name)); if (!hasPhotos) { @@ -428,8 +448,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return movie; } } - - if (result.Items.Count == 0 && multiDiscFolders.Count > 0) + else if (result.Items.Count == 0 && multiDiscFolders.Count > 0) { return GetMultiDiscMovie<T>(multiDiscFolders, directoryService); } @@ -440,7 +459,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <summary> /// Gets the multi disc movie. /// </summary> - /// <typeparam name="T"></typeparam> /// <param name="multiDiscFolders">The folders.</param> /// <param name="directoryService">The directory service.</param> /// <returns>``0.</returns> @@ -454,7 +472,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies var subFileEntries = directoryService.GetFileSystemEntries(i); var subfolders = subFileEntries - .Where(e => e.IsDirectory) + .Where(e => e.IsDirectory) .ToList(); if (subfolders.Any(s => IsDvdDirectory(s.FullName, s.Name, directoryService))) @@ -462,7 +480,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies videoTypes.Add(VideoType.Dvd); return true; } - if (subfolders.Any(s => IsBluRayDirectory(s.FullName, s.Name, directoryService))) + + if (subfolders.Any(s => IsBluRayDirectory(s.Name))) { videoTypes.Add(VideoType.BluRay); return true; @@ -479,7 +498,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } return false; - }).OrderBy(i => i).ToList(); // If different video types were found, don't allow this @@ -494,24 +512,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } var namingOptions = ((LibraryManager)LibraryManager).GetNamingOptions(); - var resolver = new StackResolver(namingOptions); - var result = resolver.ResolveDirectories(folderPaths); + var result = new StackResolver(namingOptions).ResolveDirectories(folderPaths).ToList(); - if (result.Stacks.Count != 1) + if (result.Count != 1) { return null; } + int additionalPartsLen = folderPaths.Count - 1; + var additionalParts = new string[additionalPartsLen]; + folderPaths.CopyTo(1, additionalParts, 0, additionalPartsLen); + var returnVideo = new T { Path = folderPaths[0], - - AdditionalParts = folderPaths.Skip(1).ToArray(), - + AdditionalParts = additionalParts, VideoType = videoTypes[0], - - Name = result.Stacks[0].Name + Name = result[0].Name }; SetIsoType(returnVideo); @@ -519,16 +537,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return returnVideo; } - private string[] ValidCollectionTypes = new[] - { - CollectionType.Movies, - CollectionType.HomeVideos, - CollectionType.MusicVideos, - CollectionType.Movies, - CollectionType.Photos - }; - - private bool IsInvalid(Folder parent, string collectionType) + private bool IsInvalid(Folder parent, ReadOnlySpan<char> collectionType) { if (parent != null) { @@ -538,20 +547,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies } } - if (string.IsNullOrEmpty(collectionType)) + if (collectionType.IsEmpty) { return false; } - return !ValidCollectionTypes.Contains(collectionType, StringComparer.OrdinalIgnoreCase); - } - - private IImageProcessor _imageProcessor; - - public MovieResolver(ILibraryManager libraryManager, IImageProcessor imageProcessor) - : base(libraryManager) - { - _imageProcessor = imageProcessor; + 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 82779f8d3..1c560e8a6 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; @@ -7,17 +9,28 @@ using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers { - public class PhotoAlbumResolver : FolderResolver<PhotoAlbum> + /// <summary> + /// Class PhotoAlbumResolver. + /// </summary> + public class PhotoAlbumResolver : GenericFolderResolver<PhotoAlbum> { private readonly IImageProcessor _imageProcessor; - private ILibraryManager _libraryManager; + private readonly ILibraryManager _libraryManager; + /// <summary> + /// Initializes a new instance of the <see cref="PhotoAlbumResolver"/> class. + /// </summary> + /// <param name="imageProcessor">The image processor.</param> + /// <param name="libraryManager">The library manager.</param> public PhotoAlbumResolver(IImageProcessor imageProcessor, ILibraryManager libraryManager) { _imageProcessor = imageProcessor; _libraryManager = libraryManager; } + /// <inheritdoc /> + public override ResolverPriority Priority => ResolverPriority.Second; + /// <summary> /// Resolves the specified args. /// </summary> @@ -31,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)) { @@ -55,13 +68,12 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (!file.IsDirectory && PhotoResolver.IsImageFile(file.FullName, _imageProcessor)) { - var libraryOptions = args.GetLibraryOptions(); var filename = file.Name; var ownedByMedia = false; foreach (var siblingFile in files) { - if (PhotoResolver.IsOwnedByMedia(_libraryManager, libraryOptions, siblingFile.FullName, filename)) + if (PhotoResolver.IsOwnedByMedia(_libraryManager, siblingFile.FullName, filename)) { ownedByMedia = true; break; @@ -74,9 +86,8 @@ namespace Emby.Server.Implementations.Library.Resolvers } } } + return false; } - - 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 db270c398..57bf40e9e 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.IO; @@ -5,7 +9,6 @@ using System.Linq; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers @@ -14,6 +17,18 @@ namespace Emby.Server.Implementations.Library.Resolvers { private readonly IImageProcessor _imageProcessor; private readonly ILibraryManager _libraryManager; + private static readonly HashSet<string> _ignoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "folder", + "thumb", + "landscape", + "fanart", + "backdrop", + "poster", + "cover", + "logo", + "default" + }; public PhotoResolver(IImageProcessor imageProcessor, ILibraryManager libraryManager) { @@ -31,10 +46,10 @@ namespace Emby.Server.Implementations.Library.Resolvers if (!args.IsDirectory) { // Must be an image file within a photo collection - var collectionType = args.GetCollectionType(); + var collectionType = args.CollectionType; - 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 (IsImageFile(args.Path, _imageProcessor)) { @@ -42,11 +57,10 @@ namespace Emby.Server.Implementations.Library.Resolvers // Make sure the image doesn't belong to a video file var files = args.DirectoryService.GetFiles(Path.GetDirectoryName(args.Path)); - var libraryOptions = args.GetLibraryOptions(); foreach (var file in files) { - if (IsOwnedByMedia(_libraryManager, libraryOptions, file.FullName, filename)) + if (IsOwnedByMedia(_libraryManager, file.FullName, filename)) { return null; } @@ -63,54 +77,40 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } - internal static bool IsOwnedByMedia(ILibraryManager libraryManager, LibraryOptions libraryOptions, string file, string imageFilename) + internal static bool IsOwnedByMedia(ILibraryManager libraryManager, string file, string imageFilename) { - if (libraryManager.IsVideoFile(file, libraryOptions)) + if (libraryManager.IsVideoFile(file)) { - return IsOwnedByResolvedMedia(libraryManager, libraryOptions, file, imageFilename); + return IsOwnedByResolvedMedia(libraryManager, file, imageFilename); } return false; } - internal static bool IsOwnedByResolvedMedia(ILibraryManager libraryManager, LibraryOptions libraryOptions, string file, string imageFilename) + internal static bool IsOwnedByResolvedMedia(ILibraryManager libraryManager, string file, string imageFilename) + => imageFilename.StartsWith(Path.GetFileNameWithoutExtension(file), StringComparison.OrdinalIgnoreCase); + + internal static bool IsImageFile(string path, IImageProcessor imageProcessor) { - if (imageFilename.StartsWith(Path.GetFileNameWithoutExtension(file), StringComparison.OrdinalIgnoreCase)) + if (path == null) { - return true; + throw new ArgumentNullException(nameof(path)); } - return false; - } - - private static readonly HashSet<string> IgnoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) - { - "folder", - "thumb", - "landscape", - "fanart", - "backdrop", - "poster", - "cover", - "logo", - "default" - }; - - internal static bool IsImageFile(string path, IImageProcessor imageProcessor) - { - var filename = Path.GetFileNameWithoutExtension(path) ?? string.Empty; + var filename = Path.GetFileNameWithoutExtension(path); - if (IgnoreFiles.Contains(filename)) + if (_ignoreFiles.Contains(filename)) { return false; } - if (IgnoreFiles.Any(i => filename.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1)) + if (_ignoreFiles.Any(i => filename.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1)) { return false; } - return imageProcessor.SupportedInputFormats.Contains(Path.GetExtension(path).TrimStart('.'), StringComparer.Ordinal); + string extension = Path.GetExtension(path).TrimStart('.'); + return imageProcessor.SupportedInputFormats.Contains(extension, StringComparer.OrdinalIgnoreCase); } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index c295894d3..8ce59717d 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -1,63 +1,74 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.IO; using System.Linq; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; namespace Emby.Server.Implementations.Library.Resolvers { - public class PlaylistResolver : FolderResolver<Playlist> + /// <summary> + /// <see cref="IItemResolver"/> for <see cref="Playlist"/> library items. + /// </summary> + public class PlaylistResolver : GenericFolderResolver<Playlist> { - private string[] SupportedCollectionTypes = new string[] { - + private string[] _musicPlaylistCollectionTypes = + { string.Empty, CollectionType.Music }; - /// <summary> - /// Resolves the specified args. - /// </summary> - /// <param name="args">The args.</param> - /// <returns>BoxSet.</returns> + /// <inheritdoc/> protected override Playlist Resolve(ItemResolveArgs args) { - // It's a boxset if all of the following conditions are met: - // Is a Directory - // Contains [playlist] in the path if (args.IsDirectory) { - var filename = Path.GetFileName(args.Path); - - if (string.IsNullOrEmpty(filename)) + // It's a boxset if the path is a directory with [playlist] in it's the name + // TODO: Should this use Path.GetDirectoryName() instead? + bool isBoxSet = Path.GetFileName(args.Path) + ?.Contains("[playlist]", StringComparison.OrdinalIgnoreCase) + ?? false; + if (isBoxSet) { - return null; + return new Playlist + { + Path = args.Path, + Name = Path.GetFileName(args.Path).Replace("[playlist]", string.Empty, StringComparison.OrdinalIgnoreCase).Trim() + }; } - if (filename.IndexOf("[playlist]", StringComparison.OrdinalIgnoreCase) != -1) + // It's a directory-based playlist if the directory contains a playlist file + var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true }); + if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase))) { return new Playlist { Path = args.Path, - Name = Path.GetFileName(args.Path).Replace("[playlist]", string.Empty, StringComparison.OrdinalIgnoreCase).Trim() + Name = Path.GetFileName(args.Path) }; } } - else + + // Check if this is a music playlist file + // It should have the correct collection type and a supported file extension + else if (_musicPlaylistCollectionTypes.Contains(args.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) { - if (SupportedCollectionTypes.Contains(args.CollectionType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + var extension = Path.GetExtension(args.Path); + if (Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)) { - var extension = Path.GetExtension(args.Path); - if (Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + return new Playlist { - return new Playlist - { - Path = args.Path, - Name = Path.GetFileNameWithoutExtension(args.Path), - IsInMixedFolder = true - }; - } + Path = args.Path, + Name = Path.GetFileNameWithoutExtension(args.Path), + IsInMixedFolder = true, + PlaylistMediaType = MediaType.Audio + }; } } diff --git a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs index 7e4b38b4c..6bb999641 100644 --- a/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/SpecialFolderResolver.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.IO; using System.Linq; @@ -9,7 +13,7 @@ using MediaBrowser.Model.IO; namespace Emby.Server.Implementations.Library.Resolvers { - public class SpecialFolderResolver : FolderResolver<Folder> + public class SpecialFolderResolver : GenericFolderResolver<Folder> { private readonly IFileSystem _fileSystem; private readonly IServerApplicationPaths _appPaths; @@ -39,10 +43,12 @@ namespace Emby.Server.Implementations.Library.Resolvers { return new AggregateFolder(); } + if (string.Equals(args.Path, _appPaths.DefaultUserViewsPath, StringComparison.OrdinalIgnoreCase)) { - return new UserRootFolder(); //if we got here and still a root - must be user root + return new UserRootFolder(); // if we got here and still a root - must be user root } + if (args.IsVf) { return new CollectionFolder @@ -61,7 +67,6 @@ namespace Emby.Server.Implementations.Library.Resolvers return args.FileSystemChildren .Where(i => { - try { return !i.IsDirectory && @@ -71,7 +76,6 @@ namespace Emby.Server.Implementations.Library.Resolvers { return false; } - }) .Select(i => _fileSystem.GetFileNameWithoutExtension(i)) .FirstOrDefault(); diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index a6d18c9b5..cf2d22f4d 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; @@ -7,16 +10,25 @@ using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers.TV { /// <summary> - /// Class EpisodeResolver + /// Class EpisodeResolver. /// </summary> 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,35 +37,30 @@ 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.ContainsKey(parent.Name))) { 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) { episode.SeriesId = series.Id; episode.SeriesName = series.Name; } + if (season != null) { episode.SeasonId = season.Id; @@ -72,10 +79,5 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - - 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 ce1386e91..063f67543 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -1,6 +1,7 @@ +#nullable disable + using System.Globalization; using Emby.Naming.TV; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; @@ -9,30 +10,25 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Resolvers.TV { /// <summary> - /// Class SeasonResolver + /// Class SeasonResolver. /// </summary> - public class SeasonResolver : FolderResolver<Season> + public class SeasonResolver : GenericFolderResolver<Season> { - /// <summary> - /// The _config - /// </summary> - private readonly IServerConfigurationManager _config; - private readonly ILibraryManager _libraryManager; - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); private readonly ILocalizationManager _localization; - private readonly ILogger _logger; + private readonly ILogger<SeasonResolver> _logger; /// <summary> /// Initializes a new instance of the <see cref="SeasonResolver"/> class. /// </summary> - /// <param name="config">The config.</param> /// <param name="libraryManager">The library manager.</param> - /// <param name="localization">The localization</param> - /// <param name="logger">The logger</param> - public SeasonResolver(IServerConfigurationManager config, ILibraryManager libraryManager, ILocalizationManager localization, ILogger logger) + /// <param name="localization">The localization.</param> + /// <param name="logger">The logger.</param> + public SeasonResolver( + ILibraryManager libraryManager, + ILocalizationManager localization, + ILogger<SeasonResolver> logger) { - _config = config; _libraryManager = libraryManager; _localization = localization; _logger = logger; @@ -45,14 +41,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// <returns>Season.</returns> protected override Season Resolve(ItemResolveArgs args) { - if (args.Parent is Series && args.IsDirectory) + if (args.Parent is Series series && args.IsDirectory) { var namingOptions = ((LibraryManager)_libraryManager).GetNamingOptions(); - var series = ((Series)args.Parent); var path = args.Path; - var seasonParserResult = new SeasonPathParser(namingOptions).Parse(path, true, true); + var seasonParserResult = SeasonPathParser.Parse(path, true, true); var season = new Season { @@ -74,7 +69,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { if (episodeInfo.EpisodeNumber.HasValue && episodeInfo.SeasonNumber.HasValue) { - _logger.LogDebug("Found folder underneath series with episode number: {0}. Season {1}. Episode {2}", + _logger.LogDebug( + "Found folder underneath series with episode number: {0}. Season {1}. Episode {2}", path, episodeInfo.SeasonNumber.Value, episodeInfo.EpisodeNumber.Value); @@ -90,8 +86,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV season.Name = seasonNumber == 0 ? args.LibraryOptions.SeasonZeroDisplayName : - string.Format(_localization.GetLocalizedString("NameSeasonNumber"), seasonNumber.ToString(UsCulture), args.GetLibraryOptions().PreferredMetadataLanguage); - + string.Format( + CultureInfo.InvariantCulture, + _localization.GetLocalizedString("NameSeasonNumber"), + seasonNumber, + 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 5c95534ec..b7fbe01c5 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -1,12 +1,14 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.IO; using Emby.Naming.TV; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -14,17 +16,20 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Resolvers.TV { /// <summary> - /// Class SeriesResolver + /// Class SeriesResolver. /// </summary> - public class SeriesResolver : FolderResolver<Series> + public class SeriesResolver : GenericFolderResolver<Series> { - private readonly IFileSystem _fileSystem; - private readonly ILogger _logger; + private readonly ILogger<SeriesResolver> _logger; private readonly ILibraryManager _libraryManager; - public SeriesResolver(IFileSystem fileSystem, ILogger logger, ILibraryManager libraryManager) + /// <summary> + /// Initializes a new instance of the <see cref="SeriesResolver"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + public SeriesResolver(ILogger<SeriesResolver> logger, ILibraryManager libraryManager) { - _fileSystem = fileSystem; _logger = logger; _libraryManager = libraryManager; } @@ -52,15 +57,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)) { @@ -93,7 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } - if (IsSeriesFolder(args.Path, args.FileSystemChildren, args.DirectoryService, _fileSystem, _logger, _libraryManager, args.GetLibraryOptions(), false)) + if (IsSeriesFolder(args.Path, args.FileSystemChildren, _logger, _libraryManager, false)) { return new Series { @@ -110,31 +106,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV public static bool IsSeriesFolder( string path, IEnumerable<FileSystemMetadata> fileSystemChildren, - IDirectoryService directoryService, - IFileSystem fileSystem, - ILogger logger, + ILogger<SeriesResolver> logger, ILibraryManager libraryManager, - LibraryOptions libraryOptions, bool isTvContentType) { foreach (var child in fileSystemChildren) { - //if ((attributes & FileAttributes.Hidden) == FileAttributes.Hidden) - //{ - // //logger.LogDebug("Igoring series file or folder marked hidden: {0}", child.FullName); - // continue; - //} - - // Can't enforce this because files saved by Bitcasa are always marked System - //if ((attributes & FileAttributes.System) == FileAttributes.System) - //{ - // logger.LogDebug("Igoring series subfolder marked system: {0}", child.FullName); - // continue; - //} - 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; @@ -143,7 +123,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV else { string fullName = child.FullName; - if (libraryManager.IsVideoFile(fullName, libraryOptions)) + if (libraryManager.IsVideoFile(fullName)) { if (isTvContentType) { @@ -168,35 +148,14 @@ 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 namingOptions = ((LibraryManager)libraryManager).GetNamingOptions(); - - var seasonNumber = new SeasonPathParser(namingOptions).Parse(path, isTvContentType, isTvContentType).SeasonNumber; + var seasonNumber = SeasonPathParser.Parse(path, isTvContentType, isTvContentType).SeasonNumber; return seasonNumber.HasValue; } @@ -226,7 +185,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV if (!string.IsNullOrEmpty(id)) { - item.SetProviderId(MetadataProviders.Tvdb, id); + item.SetProviderId(MetadataProvider.Tvdb, id); } } } diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs index 9c7f7dfcb..bebc25b77 100644 --- a/Emby.Server.Implementations/Library/SearchEngine.cs +++ b/Emby.Server.Implementations/Library/SearchEngine.cs @@ -1,42 +1,39 @@ +#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.Entities; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Search; -using Microsoft.Extensions.Logging; +using Genre = MediaBrowser.Controller.Entities.Genre; +using Person = MediaBrowser.Controller.Entities.Person; namespace Emby.Server.Implementations.Library { - /// <summary> - /// </summary> public class SearchEngine : ISearchEngine { private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; - private readonly ILogger _logger; - public SearchEngine(ILoggerFactory loggerFactory, ILibraryManager libraryManager, IUserManager userManager) + public SearchEngine(ILibraryManager libraryManager, IUserManager userManager) { _libraryManager = libraryManager; _userManager = userManager; - - _logger = loggerFactory.CreateLogger("SearchEngine"); } public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query) { User user = null; - - if (query.UserId.Equals(Guid.Empty)) - { - } - else + if (query.UserId != Guid.Empty) { user = _userManager.GetUserById(query.UserId); } @@ -46,19 +43,19 @@ namespace Emby.Server.Implementations.Library if (query.StartIndex.HasValue) { - results = results.Skip(query.StartIndex.Value).ToList(); + results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value); } if (query.Limit.HasValue) { - results = results.Take(query.Limit.Value).ToList(); + results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count)); } return new QueryResult<SearchHintInfo> { TotalRecordCount = totalRecordCount, - Items = results.ToArray() + Items = results }; } @@ -76,14 +73,14 @@ namespace Emby.Server.Implementations.Library /// <param name="query">The query.</param> /// <param name="user">The user.</param> /// <returns>IEnumerable{SearchHintResult}.</returns> - /// <exception cref="ArgumentNullException">searchTerm</exception> + /// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception> private List<SearchHintInfo> GetSearchHints(SearchQuery query, User user) { var searchTerm = query.SearchTerm; if (string.IsNullOrEmpty(searchTerm)) { - throw new ArgumentNullException(nameof(searchTerm)); + throw new ArgumentException("SearchTerm can't be empty.", nameof(query)); } searchTerm = searchTerm.Trim().RemoveDiacritics(); @@ -91,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) @@ -160,9 +157,9 @@ 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), - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) }, + IncludeItemsByName = !query.ParentId.HasValue, + ParentId = query.ParentId ?? Guid.Empty, + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, Recursive = true, IsKids = query.IsKids, @@ -192,6 +189,7 @@ namespace Emby.Server.Implementations.Library { searchQuery.AncestorIds = new[] { searchQuery.ParentId }; } + searchQuery.ParentId = Guid.Empty; searchQuery.IncludeItemsByName = true; searchQuery.IncludeItemTypes = Array.Empty<string>(); @@ -205,7 +203,6 @@ namespace Emby.Server.Implementations.Library return mediaItems.Select(i => new SearchHintInfo { Item = i - }).ToList(); } } diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index dfa1edaff..bb3034142 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -1,8 +1,13 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Threading; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -10,41 +15,38 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Logging; +using AudioBook = MediaBrowser.Controller.Entities.AudioBook; +using Book = MediaBrowser.Controller.Entities.Book; namespace Emby.Server.Implementations.Library { /// <summary> - /// Class UserDataManager + /// Class UserDataManager. /// </summary> public class UserDataManager : IUserDataManager { - public event EventHandler<UserDataSaveEventArgs> UserDataSaved; - private readonly ConcurrentDictionary<string, UserItemData> _userData = new ConcurrentDictionary<string, UserItemData>(StringComparer.OrdinalIgnoreCase); - private readonly ILogger _logger; private readonly IServerConfigurationManager _config; + private readonly IUserManager _userManager; + private readonly IUserDataRepository _repository; - private Func<IUserManager> _userManager; - - public UserDataManager(ILoggerFactory loggerFactory, IServerConfigurationManager config, Func<IUserManager> userManager) + public UserDataManager( + IServerConfigurationManager config, + IUserManager userManager, + IUserDataRepository repository) { _config = config; - _logger = loggerFactory.CreateLogger(GetType().Name); _userManager = userManager; + _repository = repository; } - /// <summary> - /// Gets or sets the repository. - /// </summary> - /// <value>The repository.</value> - public IUserDataRepository Repository { get; set; } + public event EventHandler<UserDataSaveEventArgs> UserDataSaved; public void SaveUserData(Guid userId, BaseItem item, UserItemData userData, UserDataSaveReason reason, CancellationToken cancellationToken) { - var user = _userManager().GetUserById(userId); + var user = _userManager.GetUserById(userId); SaveUserData(user, item, userData, reason, cancellationToken); } @@ -55,6 +57,7 @@ namespace Emby.Server.Implementations.Library { throw new ArgumentNullException(nameof(userData)); } + if (item == null) { throw new ArgumentNullException(nameof(item)); @@ -68,7 +71,7 @@ namespace Emby.Server.Implementations.Library foreach (var key in keys) { - Repository.SaveUserData(userId, key, userData, cancellationToken); + _repository.SaveUserData(userId, key, userData, cancellationToken); } var cacheKey = GetCacheKey(userId, item.Id); @@ -87,32 +90,31 @@ namespace Emby.Server.Implementations.Library /// <summary> /// Save the provided user data for the given user. Batch operation. Does not fire any events or update the cache. /// </summary> - /// <param name="userId"></param> - /// <param name="userData"></param> - /// <param name="cancellationToken"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <param name="userData">The user item data.</param> + /// <param name="cancellationToken">The cancellation token.</param> public void SaveAllUserData(Guid userId, UserItemData[] userData, CancellationToken cancellationToken) { - var user = _userManager().GetUserById(userId); + var user = _userManager.GetUserById(userId); - Repository.SaveAllUserData(user.InternalId, userData, cancellationToken); + _repository.SaveAllUserData(user.InternalId, userData, cancellationToken); } /// <summary> - /// Retrieve all user data for the given user + /// Retrieve all user data for the given user. /// </summary> - /// <param name="userId"></param> - /// <returns></returns> + /// <param name="userId">The user id.</param> + /// <returns>A <see cref="List{UserItemData}"/> containing all of the user's item data.</returns> public List<UserItemData> GetAllUserData(Guid userId) { - var user = _userManager().GetUserById(userId); + var user = _userManager.GetUserById(userId); - return Repository.GetAllUserData(user.InternalId); + return _repository.GetAllUserData(user.InternalId); } public UserItemData GetUserData(Guid userId, Guid itemId, List<string> keys) { - var user = _userManager().GetUserById(userId); + var user = _userManager.GetUserById(userId); return GetUserData(user, itemId, keys); } @@ -128,7 +130,7 @@ namespace Emby.Server.Implementations.Library private UserItemData GetUserDataInternal(long internalUserId, List<string> keys) { - var userData = Repository.GetUserData(internalUserId, keys); + var userData = _repository.GetUserData(internalUserId, keys); if (userData != null) { @@ -152,7 +154,7 @@ namespace Emby.Server.Implementations.Library /// <returns>System.String.</returns> private static string GetCacheKey(long internalUserId, Guid itemId) { - return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N"); + return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N", CultureInfo.InvariantCulture); } public UserItemData GetUserData(User user, BaseItem item) @@ -160,11 +162,6 @@ namespace Emby.Server.Implementations.Library return GetUserData(user, item.Id, item.GetUserDataKeys()); } - public UserItemData GetUserData(string userId, BaseItem item) - { - return GetUserData(new Guid(userId), item); - } - public UserItemData GetUserData(Guid userId, BaseItem item) { return GetUserData(userId, item.Id, item.GetUserDataKeys()); @@ -179,6 +176,7 @@ namespace Emby.Server.Implementations.Library return dto; } + /// <inheritdoc /> public UserItemDataDto GetUserDataDto(BaseItem item, BaseItemDto itemDto, User user, DtoOptions options) { var userData = GetUserData(user, item); @@ -189,11 +187,11 @@ namespace Emby.Server.Implementations.Library } /// <summary> - /// Converts a UserItemData to a DTOUserItemData + /// Converts a UserItemData to a DTOUserItemData. /// </summary> /// <param name="data">The data.</param> /// <returns>DtoUserItemData.</returns> - /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ArgumentNullException"><paramref name="data"/> is <c>null</c>.</exception> private UserItemDataDto GetUserItemDataDto(UserItemData data) { if (data == null) @@ -214,6 +212,7 @@ namespace Emby.Server.Implementations.Library }; } + /// <inheritdoc /> public bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks) { var playedToCompletion = false; @@ -224,28 +223,25 @@ 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; - // Don't track in very beginning if (pctIn < _config.Configuration.MinResumePct) { + // ignore progress during the beginning positionTicks = 0; } - - // If we're at the end, assume completed else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= runtimeTicks) { + // mark as completed close to the end positionTicks = 0; data.Played = playedToCompletion = true; } - else { // Enforce MinResumeDuration var durationSeconds = TimeSpan.FromTicks(runtimeTicks).TotalSeconds; - if (durationSeconds < _config.Configuration.MinResumeDurationSeconds) { positionTicks = 0; @@ -253,6 +249,23 @@ namespace Emby.Server.Implementations.Library } } } + 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 @@ -265,6 +278,7 @@ namespace Emby.Server.Implementations.Library positionTicks = 0; data.Played = false; } + if (!item.SupportsPositionTicksResume) { positionTicks = 0; diff --git a/Emby.Server.Implementations/Library/UserManager.cs b/Emby.Server.Implementations/Library/UserManager.cs deleted file mode 100644 index dfef8e997..000000000 --- a/Emby.Server.Implementations/Library/UserManager.cs +++ /dev/null @@ -1,1220 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Events; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Controller.Security; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Cryptography; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Users; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Library -{ - /// <summary> - /// Class UserManager - /// </summary> - public class UserManager : IUserManager - { - /// <summary> - /// Gets the users. - /// </summary> - /// <value>The users.</value> - public IEnumerable<User> Users => _users; - - private User[] _users; - - /// <summary> - /// The _logger - /// </summary> - private readonly ILogger _logger; - - /// <summary> - /// Gets or sets the configuration manager. - /// </summary> - /// <value>The configuration manager.</value> - private IServerConfigurationManager ConfigurationManager { get; set; } - - /// <summary> - /// Gets the active user repository - /// </summary> - /// <value>The user repository.</value> - private IUserRepository UserRepository { get; set; } - public event EventHandler<GenericEventArgs<User>> UserPasswordChanged; - - private readonly IXmlSerializer _xmlSerializer; - private readonly IJsonSerializer _jsonSerializer; - - private readonly INetworkManager _networkManager; - - private readonly Func<IImageProcessor> _imageProcessorFactory; - private readonly Func<IDtoService> _dtoServiceFactory; - private readonly IServerApplicationHost _appHost; - private readonly IFileSystem _fileSystem; - - private IAuthenticationProvider[] _authenticationProviders; - private DefaultAuthenticationProvider _defaultAuthenticationProvider; - - public UserManager( - ILoggerFactory loggerFactory, - IServerConfigurationManager configurationManager, - IUserRepository userRepository, - IXmlSerializer xmlSerializer, - INetworkManager networkManager, - Func<IImageProcessor> imageProcessorFactory, - Func<IDtoService> dtoServiceFactory, - IServerApplicationHost appHost, - IJsonSerializer jsonSerializer, - IFileSystem fileSystem) - { - _logger = loggerFactory.CreateLogger(nameof(UserManager)); - UserRepository = userRepository; - _xmlSerializer = xmlSerializer; - _networkManager = networkManager; - _imageProcessorFactory = imageProcessorFactory; - _dtoServiceFactory = dtoServiceFactory; - _appHost = appHost; - _jsonSerializer = jsonSerializer; - _fileSystem = fileSystem; - ConfigurationManager = configurationManager; - _users = Array.Empty<User>(); - - DeletePinFile(); - } - - public NameIdPair[] GetAuthenticationProviders() - { - return _authenticationProviders - .Where(i => i.IsEnabled) - .OrderBy(i => i is DefaultAuthenticationProvider ? 0 : 1) - .ThenBy(i => i.Name) - .Select(i => new NameIdPair - { - Name = i.Name, - Id = GetAuthenticationProviderId(i) - }) - .ToArray(); - } - - public void AddParts(IEnumerable<IAuthenticationProvider> authenticationProviders) - { - _authenticationProviders = authenticationProviders.ToArray(); - - _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First(); - } - - #region UserUpdated Event - /// <summary> - /// Occurs when [user updated]. - /// </summary> - public event EventHandler<GenericEventArgs<User>> UserUpdated; - public event EventHandler<GenericEventArgs<User>> UserPolicyUpdated; - public event EventHandler<GenericEventArgs<User>> UserConfigurationUpdated; - public event EventHandler<GenericEventArgs<User>> UserLockedOut; - - /// <summary> - /// Called when [user updated]. - /// </summary> - /// <param name="user">The user.</param> - private void OnUserUpdated(User user) - { - UserUpdated?.Invoke(this, new GenericEventArgs<User> { Argument = user }); - } - #endregion - - #region UserDeleted Event - /// <summary> - /// Occurs when [user deleted]. - /// </summary> - public event EventHandler<GenericEventArgs<User>> UserDeleted; - /// <summary> - /// Called when [user deleted]. - /// </summary> - /// <param name="user">The user.</param> - private void OnUserDeleted(User user) - { - UserDeleted?.Invoke(this, new GenericEventArgs<User> { Argument = user }); - } - #endregion - - /// <summary> - /// Gets a User by Id - /// </summary> - /// <param name="id">The id.</param> - /// <returns>User.</returns> - /// <exception cref="ArgumentNullException"></exception> - public User GetUserById(Guid id) - { - if (id == Guid.Empty) - { - throw new ArgumentException(nameof(id), "Guid can't be empty"); - } - - return Users.FirstOrDefault(u => u.Id == id); - } - - /// <summary> - /// Gets the user by identifier. - /// </summary> - /// <param name="id">The identifier.</param> - /// <returns>User.</returns> - public User GetUserById(string id) - { - return GetUserById(new Guid(id)); - } - - public User GetUserByName(string name) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - return Users.FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase)); - } - - public void Initialize() - { - _users = LoadUsers(); - - var users = Users.ToList(); - - // If there are no local users with admin rights, make them all admins - if (!users.Any(i => i.Policy.IsAdministrator)) - { - foreach (var user in users) - { - user.Policy.IsAdministrator = true; - UpdateUserPolicy(user, user.Policy, false); - } - } - } - - public bool IsValidUsername(string username) - { - // Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.) - foreach (var currentChar in username) - { - if (!IsValidUsernameCharacter(currentChar)) - { - return false; - } - } - return true; - } - - private static bool IsValidUsernameCharacter(char i) - { - return !char.Equals(i, '<') && !char.Equals(i, '>'); - } - - public string MakeValidUsername(string username) - { - if (IsValidUsername(username)) - { - return username; - } - - // Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.) - var builder = new StringBuilder(); - - foreach (var c in username) - { - if (IsValidUsernameCharacter(c)) - { - builder.Append(c); - } - } - return builder.ToString(); - } - - public async Task<User> AuthenticateUser(string username, string password, string hashedPassword, string remoteEndPoint, bool isUserSession) - { - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentNullException(nameof(username)); - } - - var user = Users - .FirstOrDefault(i => string.Equals(username, i.Name, StringComparison.OrdinalIgnoreCase)); - - var success = false; - IAuthenticationProvider authenticationProvider = null; - - if (user != null) - { - var authResult = await AuthenticateLocalUser(username, password, hashedPassword, user, remoteEndPoint).ConfigureAwait(false); - authenticationProvider = authResult.Item1; - success = authResult.Item2; - } - else - { - // user is null - var authResult = await AuthenticateLocalUser(username, password, hashedPassword, null, remoteEndPoint).ConfigureAwait(false); - authenticationProvider = authResult.Item1; - success = authResult.Item2; - - if (success && authenticationProvider != null && !(authenticationProvider is DefaultAuthenticationProvider)) - { - user = await CreateUser(username).ConfigureAwait(false); - - var hasNewUserPolicy = authenticationProvider as IHasNewUserPolicy; - if (hasNewUserPolicy != null) - { - var policy = hasNewUserPolicy.GetNewUserPolicy(); - UpdateUserPolicy(user, policy, true); - } - } - } - - if (success && user != null && authenticationProvider != null) - { - var providerId = GetAuthenticationProviderId(authenticationProvider); - - if (!string.Equals(providerId, user.Policy.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) - { - user.Policy.AuthenticationProviderId = providerId; - UpdateUserPolicy(user, user.Policy, true); - } - } - - if (user == null) - { - throw new SecurityException("Invalid username or password entered."); - } - - if (user.Policy.IsDisabled) - { - throw new SecurityException(string.Format("The {0} account is currently disabled. Please consult with your administrator.", user.Name)); - } - - if (!user.Policy.EnableRemoteAccess && !_networkManager.IsInLocalNetwork(remoteEndPoint)) - { - throw new SecurityException("Forbidden."); - } - - if (!user.IsParentalScheduleAllowed()) - { - throw new SecurityException("User is not allowed access at this time."); - } - - // Update LastActivityDate and LastLoginDate, then save - if (success) - { - if (isUserSession) - { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; - UpdateUser(user); - } - UpdateInvalidLoginAttemptCount(user, 0); - } - else - { - UpdateInvalidLoginAttemptCount(user, user.Policy.InvalidLoginAttemptCount + 1); - } - - _logger.LogInformation("Authentication request for {0} {1}.", user.Name, success ? "has succeeded" : "has been denied"); - - return success ? user : null; - } - - private static string GetAuthenticationProviderId(IAuthenticationProvider provider) - { - return provider.GetType().FullName; - } - - private IAuthenticationProvider GetAuthenticationProvider(User user) - { - return GetAuthenticationProviders(user).First(); - } - - private IAuthenticationProvider[] GetAuthenticationProviders(User user) - { - var authenticationProviderId = user == null ? null : user.Policy.AuthenticationProviderId; - - var providers = _authenticationProviders.Where(i => i.IsEnabled).ToArray(); - - if (!string.IsNullOrEmpty(authenticationProviderId)) - { - providers = providers.Where(i => string.Equals(authenticationProviderId, GetAuthenticationProviderId(i), StringComparison.OrdinalIgnoreCase)).ToArray(); - } - - if (providers.Length == 0) - { - providers = new IAuthenticationProvider[] { _defaultAuthenticationProvider }; - } - - return providers; - } - - private async Task<bool> AuthenticateWithProvider(IAuthenticationProvider provider, string username, string password, User resolvedUser) - { - try - { - var requiresResolvedUser = provider as IRequiresResolvedUser; - if (requiresResolvedUser != null) - { - await requiresResolvedUser.Authenticate(username, password, resolvedUser).ConfigureAwait(false); - } - else - { - await provider.Authenticate(username, password).ConfigureAwait(false); - } - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error authenticating with provider {provider}", provider.Name); - - return false; - } - } - - private async Task<Tuple<IAuthenticationProvider, bool>> AuthenticateLocalUser(string username, string password, string hashedPassword, User user, string remoteEndPoint) - { - bool success = false; - IAuthenticationProvider authenticationProvider = null; - - if (password != null && user != null) - { - // Doesn't look like this is even possible to be used, because of password == null checks below - hashedPassword = _defaultAuthenticationProvider.GetHashedString(user, password); - } - - if (password == null) - { - // legacy - success = string.Equals(_defaultAuthenticationProvider.GetPasswordHash(user), hashedPassword.Replace("-", string.Empty), StringComparison.OrdinalIgnoreCase); - } - else - { - foreach (var provider in GetAuthenticationProviders(user)) - { - success = await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false); - - if (success) - { - authenticationProvider = provider; - break; - } - } - } - - if (user != null) - { - if (!success && _networkManager.IsInLocalNetwork(remoteEndPoint) && user.Configuration.EnableLocalPassword) - { - if (password == null) - { - // legacy - success = string.Equals(GetLocalPasswordHash(user), hashedPassword.Replace("-", string.Empty), StringComparison.OrdinalIgnoreCase); - } - else - { - success = string.Equals(GetLocalPasswordHash(user), _defaultAuthenticationProvider.GetHashedString(user, password), StringComparison.OrdinalIgnoreCase); - } - } - } - - return new Tuple<IAuthenticationProvider, bool>(authenticationProvider, success); - } - - private void UpdateInvalidLoginAttemptCount(User user, int newValue) - { - if (user.Policy.InvalidLoginAttemptCount == newValue || newValue <= 0) - { - return; - } - - user.Policy.InvalidLoginAttemptCount = newValue; - - var maxCount = user.Policy.IsAdministrator ? 3 : 5; - - var fireLockout = false; - - if (newValue >= maxCount) - { - _logger.LogDebug("Disabling user {0} due to {1} unsuccessful login attempts.", user.Name, newValue); - user.Policy.IsDisabled = true; - - fireLockout = true; - } - - UpdateUserPolicy(user, user.Policy, false); - - if (fireLockout) - { - UserLockedOut?.Invoke(this, new GenericEventArgs<User>(user)); - } - } - - private string GetLocalPasswordHash(User user) - { - return string.IsNullOrEmpty(user.EasyPassword) - ? _defaultAuthenticationProvider.GetEmptyHashedString(user) - : user.EasyPassword; - } - - private bool IsPasswordEmpty(User user, string passwordHash) - { - return string.Equals(passwordHash, _defaultAuthenticationProvider.GetEmptyHashedString(user), StringComparison.OrdinalIgnoreCase); - } - - /// <summary> - /// Loads the users from the repository - /// </summary> - /// <returns>IEnumerable{User}.</returns> - private User[] LoadUsers() - { - var users = UserRepository.RetrieveAllUsers(); - - // There always has to be at least one user. - if (users.Count == 0) - { - var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName)) - { - defaultName = "MyJellyfinUser"; - } - var name = MakeValidUsername(defaultName); - - var user = InstantiateNewUser(name); - - user.DateLastSaved = DateTime.UtcNow; - - UserRepository.CreateUser(user); - - users.Add(user); - - user.Policy.IsAdministrator = true; - user.Policy.EnableContentDeletion = true; - user.Policy.EnableRemoteControlOfOtherUsers = true; - UpdateUserPolicy(user, user.Policy, false); - } - - return users.ToArray(); - } - - public UserDto GetUserDto(User user, string remoteEndPoint = null) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - var hasConfiguredPassword = GetAuthenticationProvider(user).HasPassword(user).Result; - var hasConfiguredEasyPassword = !IsPasswordEmpty(user, GetLocalPasswordHash(user)); - - var hasPassword = user.Configuration.EnableLocalPassword && !string.IsNullOrEmpty(remoteEndPoint) && _networkManager.IsInLocalNetwork(remoteEndPoint) ? - hasConfiguredEasyPassword : - hasConfiguredPassword; - - var dto = new UserDto - { - Id = user.Id, - Name = user.Name, - HasPassword = hasPassword, - HasConfiguredPassword = hasConfiguredPassword, - HasConfiguredEasyPassword = hasConfiguredEasyPassword, - LastActivityDate = user.LastActivityDate, - LastLoginDate = user.LastLoginDate, - Configuration = user.Configuration, - ServerId = _appHost.SystemId, - Policy = user.Policy - }; - - if (!hasPassword && Users.Count() == 1) - { - dto.EnableAutoLogin = true; - } - - var image = user.GetImageInfo(ImageType.Primary, 0); - - if (image != null) - { - dto.PrimaryImageTag = GetImageCacheTag(user, image); - - try - { - _dtoServiceFactory().AttachPrimaryImageAspectRatio(dto, user); - } - 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 {user}", user.Name); - } - } - - return dto; - } - - public UserDto GetOfflineUserDto(User user) - { - var dto = GetUserDto(user); - - dto.ServerName = _appHost.FriendlyName; - - return dto; - } - - private string GetImageCacheTag(BaseItem item, ItemImageInfo image) - { - try - { - return _imageProcessorFactory().GetImageCacheTag(item, image); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting {imageType} image info for {imagePath}", image.Type, image.Path); - return null; - } - } - - /// <summary> - /// Refreshes metadata for each user - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - public async Task RefreshUsersMetadata(CancellationToken cancellationToken) - { - foreach (var user in Users) - { - await user.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)), cancellationToken).ConfigureAwait(false); - } - } - - /// <summary> - /// Renames the user. - /// </summary> - /// <param name="user">The user.</param> - /// <param name="newName">The new name.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">user</exception> - /// <exception cref="ArgumentException"></exception> - public async Task RenameUser(User user, string newName) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (string.IsNullOrEmpty(newName)) - { - throw new ArgumentNullException(nameof(newName)); - } - - if (Users.Any(u => u.Id != user.Id && u.Name.Equals(newName, StringComparison.OrdinalIgnoreCase))) - { - throw new ArgumentException(string.Format("A user with the name '{0}' already exists.", newName)); - } - - if (user.Name.Equals(newName, StringComparison.Ordinal)) - { - throw new ArgumentException("The new and old names must be different."); - } - - await user.Rename(newName); - - OnUserUpdated(user); - } - - /// <summary> - /// Updates the user. - /// </summary> - /// <param name="user">The user.</param> - /// <exception cref="ArgumentNullException">user</exception> - /// <exception cref="ArgumentException"></exception> - public void UpdateUser(User user) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (user.Id.Equals(Guid.Empty) || !Users.Any(u => u.Id.Equals(user.Id))) - { - throw new ArgumentException(string.Format("User with name '{0}' and Id {1} does not exist.", user.Name, user.Id)); - } - - user.DateModified = DateTime.UtcNow; - user.DateLastSaved = DateTime.UtcNow; - - UserRepository.UpdateUser(user); - - OnUserUpdated(user); - } - - public event EventHandler<GenericEventArgs<User>> UserCreated; - - private readonly SemaphoreSlim _userListLock = new SemaphoreSlim(1, 1); - - /// <summary> - /// Creates the user. - /// </summary> - /// <param name="name">The name.</param> - /// <returns>User.</returns> - /// <exception cref="ArgumentNullException">name</exception> - /// <exception cref="ArgumentException"></exception> - public async Task<User> CreateUser(string name) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - if (!IsValidUsername(name)) - { - throw new ArgumentException("Usernames can contain letters (a-z), numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)"); - } - - if (Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) - { - throw new ArgumentException(string.Format("A user with the name '{0}' already exists.", name)); - } - - await _userListLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); - - try - { - var user = InstantiateNewUser(name); - - var list = Users.ToList(); - list.Add(user); - _users = list.ToArray(); - - user.DateLastSaved = DateTime.UtcNow; - - UserRepository.CreateUser(user); - - EventHelper.QueueEventIfNotNull(UserCreated, this, new GenericEventArgs<User> { Argument = user }, _logger); - - return user; - } - finally - { - _userListLock.Release(); - } - } - - /// <summary> - /// Deletes the user. - /// </summary> - /// <param name="user">The user.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">user</exception> - /// <exception cref="ArgumentException"></exception> - public async Task DeleteUser(User user) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - var allUsers = Users.ToList(); - - if (allUsers.FirstOrDefault(u => u.Id == user.Id) == null) - { - throw new ArgumentException(string.Format("The user cannot be deleted because there is no user with the Name {0} and Id {1}.", user.Name, user.Id)); - } - - if (allUsers.Count == 1) - { - throw new ArgumentException(string.Format("The user '{0}' cannot be deleted because there must be at least one user in the system.", user.Name)); - } - - if (user.Policy.IsAdministrator && allUsers.Count(i => i.Policy.IsAdministrator) == 1) - { - throw new ArgumentException(string.Format("The user '{0}' cannot be deleted because there must be at least one admin user in the system.", user.Name)); - } - - await _userListLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); - - try - { - var configPath = GetConfigurationFilePath(user); - - UserRepository.DeleteUser(user); - - try - { - _fileSystem.DeleteFile(configPath); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting file {path}", configPath); - } - - DeleteUserPolicy(user); - - _users = allUsers.Where(i => i.Id != user.Id).ToArray(); - - OnUserDeleted(user); - } - finally - { - _userListLock.Release(); - } - } - - /// <summary> - /// Resets the password by clearing it. - /// </summary> - /// <returns>Task.</returns> - public Task ResetPassword(User user) - { - return ChangePassword(user, string.Empty); - } - - public void ResetEasyPassword(User user) - { - ChangeEasyPassword(user, string.Empty, null); - } - - public async Task ChangePassword(User user, string newPassword) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false); - - UpdateUser(user); - - UserPasswordChanged?.Invoke(this, new GenericEventArgs<User>(user)); - } - - public void ChangeEasyPassword(User user, string newPassword, string newPasswordHash) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (newPassword != null) - { - newPasswordHash = _defaultAuthenticationProvider.GetHashedString(user, newPassword); - } - - if (string.IsNullOrWhiteSpace(newPasswordHash)) - { - throw new ArgumentNullException(nameof(newPasswordHash)); - } - - user.EasyPassword = newPasswordHash; - - UpdateUser(user); - - UserPasswordChanged?.Invoke(this, new GenericEventArgs<User>(user)); - } - - /// <summary> - /// Instantiates the new user. - /// </summary> - /// <param name="name">The name.</param> - /// <returns>User.</returns> - private static User InstantiateNewUser(string name) - { - return new User - { - Name = name, - Id = Guid.NewGuid(), - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - UsesIdForConfigurationPath = true, - //Salt = BCrypt.GenerateSalt() - }; - } - - private string PasswordResetFile => Path.Combine(ConfigurationManager.ApplicationPaths.ProgramDataPath, "passwordreset.txt"); - - private string _lastPin; - private PasswordPinCreationResult _lastPasswordPinCreationResult; - private int _pinAttempts; - - private async Task<PasswordPinCreationResult> CreatePasswordResetPin() - { - var num = new Random().Next(1, 9999); - - var path = PasswordResetFile; - - var pin = num.ToString("0000", CultureInfo.InvariantCulture); - _lastPin = pin; - - var time = TimeSpan.FromMinutes(5); - var expiration = DateTime.UtcNow.Add(time); - - var text = new StringBuilder(); - - var localAddress = (await _appHost.GetLocalApiUrl(CancellationToken.None).ConfigureAwait(false)) ?? string.Empty; - - text.AppendLine("Use your web browser to visit:"); - text.AppendLine(string.Empty); - text.AppendLine(localAddress + "/web/index.html#!/forgotpasswordpin.html"); - text.AppendLine(string.Empty); - text.AppendLine("Enter the following pin code:"); - text.AppendLine(string.Empty); - text.AppendLine(pin); - text.AppendLine(string.Empty); - - var localExpirationTime = expiration.ToLocalTime(); - // Tuesday, 22 August 2006 06:30 AM - text.AppendLine("The pin code will expire at " + localExpirationTime.ToString("f1", CultureInfo.CurrentCulture)); - - File.WriteAllText(path, text.ToString(), Encoding.UTF8); - - var result = new PasswordPinCreationResult - { - PinFile = path, - ExpirationDate = expiration - }; - - _lastPasswordPinCreationResult = result; - _pinAttempts = 0; - - return result; - } - - public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) - { - DeletePinFile(); - - var user = string.IsNullOrWhiteSpace(enteredUsername) ? - null : - GetUserByName(enteredUsername); - - var action = ForgotPasswordAction.InNetworkRequired; - string pinFile = null; - DateTime? expirationDate = null; - - if (user != null && !user.Policy.IsAdministrator) - { - action = ForgotPasswordAction.ContactAdmin; - } - else - { - if (isInNetwork) - { - action = ForgotPasswordAction.PinCode; - } - - var result = await CreatePasswordResetPin().ConfigureAwait(false); - pinFile = result.PinFile; - expirationDate = result.ExpirationDate; - } - - return new ForgotPasswordResult - { - Action = action, - PinFile = pinFile, - PinExpirationDate = expirationDate - }; - } - - public async Task<PinRedeemResult> RedeemPasswordResetPin(string pin) - { - DeletePinFile(); - - var usersReset = new List<string>(); - - var valid = !string.IsNullOrWhiteSpace(_lastPin) && - string.Equals(_lastPin, pin, StringComparison.OrdinalIgnoreCase) && - _lastPasswordPinCreationResult != null && - _lastPasswordPinCreationResult.ExpirationDate > DateTime.UtcNow; - - if (valid) - { - _lastPin = null; - _lastPasswordPinCreationResult = null; - - foreach (var user in Users) - { - await ResetPassword(user).ConfigureAwait(false); - - if (user.Policy.IsDisabled) - { - user.Policy.IsDisabled = false; - UpdateUserPolicy(user, user.Policy, true); - } - usersReset.Add(user.Name); - } - } - else - { - _pinAttempts++; - if (_pinAttempts >= 3) - { - _lastPin = null; - _lastPasswordPinCreationResult = null; - } - } - - return new PinRedeemResult - { - Success = valid, - UsersReset = usersReset.ToArray() - }; - } - - private void DeletePinFile() - { - try - { - _fileSystem.DeleteFile(PasswordResetFile); - } - catch - { - - } - } - - class PasswordPinCreationResult - { - public string PinFile { get; set; } - public DateTime ExpirationDate { get; set; } - } - - public UserPolicy GetUserPolicy(User user) - { - var path = GetPolicyFilePath(user); - - if (!File.Exists(path)) - { - return GetDefaultPolicy(user); - } - - try - { - lock (_policySyncLock) - { - return (UserPolicy)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), path); - } - } - catch (IOException) - { - return GetDefaultPolicy(user); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error reading policy file: {path}", path); - - return GetDefaultPolicy(user); - } - } - - private static UserPolicy GetDefaultPolicy(User user) - { - return new UserPolicy - { - EnableContentDownloading = true, - EnableSyncTranscoding = true - }; - } - - private readonly object _policySyncLock = new object(); - public void UpdateUserPolicy(Guid userId, UserPolicy userPolicy) - { - var user = GetUserById(userId); - UpdateUserPolicy(user, userPolicy, true); - } - - private void UpdateUserPolicy(User user, UserPolicy userPolicy, bool fireEvent) - { - // The xml serializer will output differently if the type is not exact - if (userPolicy.GetType() != typeof(UserPolicy)) - { - var json = _jsonSerializer.SerializeToString(userPolicy); - userPolicy = _jsonSerializer.DeserializeFromString<UserPolicy>(json); - } - - var path = GetPolicyFilePath(user); - - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - lock (_policySyncLock) - { - _xmlSerializer.SerializeToFile(userPolicy, path); - user.Policy = userPolicy; - } - - if (fireEvent) - { - UserPolicyUpdated?.Invoke(this, new GenericEventArgs<User> { Argument = user }); - } - } - - private void DeleteUserPolicy(User user) - { - var path = GetPolicyFilePath(user); - - try - { - lock (_policySyncLock) - { - _fileSystem.DeleteFile(path); - } - } - catch (IOException) - { - - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting policy file"); - } - } - - private static string GetPolicyFilePath(User user) - { - return Path.Combine(user.ConfigurationDirectoryPath, "policy.xml"); - } - - private static string GetConfigurationFilePath(User user) - { - return Path.Combine(user.ConfigurationDirectoryPath, "config.xml"); - } - - public UserConfiguration GetUserConfiguration(User user) - { - var path = GetConfigurationFilePath(user); - - if (!File.Exists(path)) - { - return new UserConfiguration(); - } - - try - { - lock (_configSyncLock) - { - return (UserConfiguration)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), path); - } - } - catch (IOException) - { - return new UserConfiguration(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error reading policy file: {path}", path); - - return new UserConfiguration(); - } - } - - private readonly object _configSyncLock = new object(); - public void UpdateConfiguration(Guid userId, UserConfiguration config) - { - var user = GetUserById(userId); - UpdateConfiguration(user, config); - } - - public void UpdateConfiguration(User user, UserConfiguration config) - { - UpdateConfiguration(user, config, true); - } - - private void UpdateConfiguration(User user, UserConfiguration config, bool fireEvent) - { - var path = GetConfigurationFilePath(user); - - // The xml serializer will output differently if the type is not exact - if (config.GetType() != typeof(UserConfiguration)) - { - var json = _jsonSerializer.SerializeToString(config); - config = _jsonSerializer.DeserializeFromString<UserConfiguration>(json); - } - - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - lock (_configSyncLock) - { - _xmlSerializer.SerializeToFile(config, path); - user.Configuration = config; - } - - if (fireEvent) - { - UserConfigurationUpdated?.Invoke(this, new GenericEventArgs<User> { Argument = user }); - } - } - } - - public class DeviceAccessEntryPoint : IServerEntryPoint - { - private IUserManager _userManager; - private IAuthenticationRepository _authRepo; - private IDeviceManager _deviceManager; - private ISessionManager _sessionManager; - - public DeviceAccessEntryPoint(IUserManager userManager, IAuthenticationRepository authRepo, IDeviceManager deviceManager, ISessionManager sessionManager) - { - _userManager = userManager; - _authRepo = authRepo; - _deviceManager = deviceManager; - _sessionManager = sessionManager; - } - - public Task RunAsync() - { - _userManager.UserPolicyUpdated += _userManager_UserPolicyUpdated; - - return Task.CompletedTask; - } - - private void _userManager_UserPolicyUpdated(object sender, GenericEventArgs<User> e) - { - var user = e.Argument; - if (!user.Policy.EnableAllDevices) - { - UpdateDeviceAccess(user); - } - } - - private void UpdateDeviceAccess(User user) - { - var existing = _authRepo.Get(new AuthenticationInfoQuery - { - UserId = user.Id - - }).Items; - - foreach (var authInfo in existing) - { - if (!string.IsNullOrEmpty(authInfo.DeviceId) && !_deviceManager.CanAccessDevice(user, authInfo.DeviceId)) - { - _sessionManager.Logout(authInfo); - } - } - } - - public void Dispose() - { - - } - } -} diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index e9ce682ee..711647e7a 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -1,7 +1,13 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -14,6 +20,8 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; +using Genre = MediaBrowser.Controller.Entities.Genre; +using Person = MediaBrowser.Controller.Entities.Person; namespace Emby.Server.Implementations.Library { @@ -41,6 +49,11 @@ namespace Emby.Server.Implementations.Library { var user = _userManager.GetUserById(query.UserId); + if (user == null) + { + throw new ArgumentException("User Id specified in the query does not exist.", nameof(query)); + } + var folders = _libraryManager.GetUserRootFolder() .GetChildren(user, true) .OfType<Folder>() @@ -53,7 +66,7 @@ namespace Emby.Server.Implementations.Library foreach (var folder in folders) { var collectionFolder = folder as ICollectionFolder; - var folderViewType = collectionFolder == null ? null : collectionFolder.CollectionType; + var folderViewType = collectionFolder?.CollectionType; if (UserView.IsUserSpecific(folder)) { @@ -117,28 +130,23 @@ namespace Emby.Server.Implementations.Library if (!query.IncludeHidden) { - list = list.Where(i => !user.Configuration.MyMediaExcludes.Contains(i.Id.ToString("N"))).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.Configuration.OrderedViews.ToList(); + var orders = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews); return list .OrderBy(i => { - var index = orders.IndexOf(i.Id.ToString("N")); + var index = Array.IndexOf(orders, i.Id); - if (index == -1) + if (index == -1 + && i is UserView view + && view.DisplayParentId != Guid.Empty) { - var view = i as UserView; - if (view != null) - { - if (!view.DisplayParentId.Equals(Guid.Empty)) - { - index = orders.IndexOf(view.DisplayParentId.ToString("N")); - } - } + index = Array.IndexOf(orders, view.DisplayParentId); } return index == -1 ? int.MaxValue : index; @@ -162,7 +170,13 @@ namespace Emby.Server.Implementations.Library return GetUserSubViewWithName(name, parentId, type, sortName); } - private Folder GetUserView(List<ICollectionFolder> parents, string viewType, string localizationKey, string sortName, User user, string[] presetViews) + private Folder GetUserView( + List<ICollectionFolder> parents, + string viewType, + string localizationKey, + string sortName, + Jellyfin.Data.Entities.User user, + string[] presetViews) { if (parents.Count == 1 && parents.All(i => string.Equals(i.CollectionType, viewType, StringComparison.OrdinalIgnoreCase))) { @@ -223,7 +237,7 @@ namespace Emby.Server.Implementations.Library return list; } - private List<BaseItem> GetItemsForLatestItems(User user, LatestItemsQuery request, DtoOptions options) + private IReadOnlyList<BaseItem> GetItemsForLatestItems(User user, LatestItemsQuery request, DtoOptions options) { var parentId = request.ParentId; @@ -235,24 +249,22 @@ namespace Emby.Server.Implementations.Library if (!parentId.Equals(Guid.Empty)) { var parentItem = _libraryManager.GetItemById(parentId); - var parentItemChannel = parentItem as Channel; - if (parentItemChannel != null) + if (parentItem is Channel) { - return _channelManager.GetLatestChannelItemsInternal(new InternalItemsQuery(user) - { - ChannelIds = new[] { parentId }, - IsPlayed = request.IsPlayed, - StartIndex = request.StartIndex, - Limit = request.Limit, - IncludeItemTypes = request.IncludeItemTypes, - EnableTotalRecordCount = false - - - }, CancellationToken.None).Result.Items.ToList(); + return _channelManager.GetLatestChannelItemsInternal( + new InternalItemsQuery(user) + { + ChannelIds = new[] { parentId }, + IsPlayed = request.IsPlayed, + StartIndex = request.StartIndex, + Limit = request.Limit, + IncludeItemTypes = request.IncludeItemTypes, + EnableTotalRecordCount = false + }, + CancellationToken.None).GetAwaiter().GetResult().Items; } - var parent = parentItem as Folder; - if (parent != null) + if (parentItem is Folder parent) { parents.Add(parent); } @@ -269,7 +281,8 @@ namespace Emby.Server.Implementations.Library { parents = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !user.Configuration.LatestItemsExcludes.Contains(i.Id.ToString("N"))) + .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes) + .Contains(i.Id)) .ToList(); } @@ -328,20 +341,26 @@ namespace Emby.Server.Implementations.Library mediaTypes = mediaTypes.Distinct().ToList(); } - var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Count == 0 ? new[] - { - typeof(Person).Name, - typeof(Studio).Name, - typeof(Year).Name, - typeof(MusicGenre).Name, - typeof(Genre).Name - - } : Array.Empty<string>(); + var excludeItemTypes = includeItemTypes.Length == 0 && mediaTypes.Count == 0 + ? new[] + { + nameof(Person), + nameof(Studio), + nameof(Year), + nameof(MusicGenre), + nameof(Genre) + } + : Array.Empty<string>(); var query = new InternalItemsQuery(user) { IncludeItemTypes = includeItemTypes, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DateCreated, SortOrder.Descending) }, + OrderBy = new[] + { + (ItemSortBy.DateCreated, SortOrder.Descending), + (ItemSortBy.SortName, SortOrder.Descending), + (ItemSortBy.ProductionYear, SortOrder.Descending) + }, IsFolder = includeItemTypes.Length == 0 ? false : (bool?)null, ExcludeItemTypes = excludeItemTypes, IsVirtualItem = false, diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs index 27261d449..d51f9aaa7 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs @@ -8,22 +8,27 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { /// <summary> - /// Class ArtistsPostScanTask + /// Class ArtistsPostScanTask. /// </summary> public class ArtistsPostScanTask : ILibraryPostScanTask { /// <summary> - /// The _library manager + /// The _library manager. /// </summary> private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + private readonly ILogger<ArtistsValidator> _logger; private readonly IItemRepository _itemRepo; /// <summary> /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> - public ArtistsPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <param name="logger">The logger.</param> + /// <param name="itemRepo">The item repository.</param> + public ArtistsPostScanTask( + ILibraryManager libraryManager, + ILogger<ArtistsValidator> logger, + IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs index 294348660..40436d9c5 100644 --- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -11,27 +12,28 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { /// <summary> - /// Class ArtistsValidator + /// Class ArtistsValidator. /// </summary> public class ArtistsValidator { /// <summary> - /// The _library manager + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; /// <summary> - /// The _logger + /// The logger. /// </summary> - private readonly ILogger _logger; + private readonly ILogger<ArtistsValidator> _logger; private readonly IItemRepository _itemRepo; /// <summary> - /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class. + /// Initializes a new instance of the <see cref="ArtistsValidator" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> - public ArtistsValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <param name="itemRepo">The item repository.</param> + public ArtistsValidator(ILibraryManager libraryManager, ILogger<ArtistsValidator> logger, IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; @@ -79,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(); @@ -91,13 +93,15 @@ namespace Emby.Server.Implementations.Library.Validators continue; } - _logger.LogInformation("Deleting dead {2} {0} {1}.", item.Id.ToString("N"), item.Name, item.GetType().Name); + _logger.LogInformation("Deleting dead {2} {0} {1}.", item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name, item.GetType().Name); - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false - - }, false); + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); } progress.Report(100); diff --git a/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs index 056807300..d21d2887b 100644 --- a/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs @@ -7,21 +7,28 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { + /// <summary> + /// Class GenresPostScanTask. + /// </summary> public class GenresPostScanTask : ILibraryPostScanTask { /// <summary> - /// The _library manager + /// The _library manager. /// </summary> private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + private readonly ILogger<GenresValidator> _logger; private readonly IItemRepository _itemRepo; /// <summary> - /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class. + /// Initializes a new instance of the <see cref="GenresPostScanTask" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> - public GenresPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <param name="itemRepo">The item repository.</param> + public GenresPostScanTask( + ILibraryManager libraryManager, + ILogger<GenresValidator> logger, + IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs index f8459c61f..e59c62e23 100644 --- a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs @@ -7,20 +7,29 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { - class GenresValidator + /// <summary> + /// Class GenresValidator. + /// </summary> + public class GenresValidator { /// <summary> - /// The _library manager + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; private readonly IItemRepository _itemRepo; /// <summary> - /// The _logger + /// The logger. /// </summary> - private readonly ILogger _logger; + private readonly ILogger<GenresValidator> _logger; - public GenresValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <summary> + /// Initializes a new instance of the <see cref="GenresValidator"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="itemRepo">The item repository.</param> + public GenresValidator(ILibraryManager libraryManager, ILogger<GenresValidator> logger, IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs index d7ab92d30..be119866b 100644 --- a/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs @@ -8,23 +8,27 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { /// <summary> - /// Class MusicGenresPostScanTask + /// Class MusicGenresPostScanTask. /// </summary> public class MusicGenresPostScanTask : ILibraryPostScanTask { /// <summary> - /// The _library manager + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + private readonly ILogger<MusicGenresValidator> _logger; private readonly IItemRepository _itemRepo; /// <summary> - /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class. + /// Initializes a new instance of the <see cref="MusicGenresPostScanTask" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> - public MusicGenresPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <param name="itemRepo">The item repository.</param> + public MusicGenresPostScanTask( + ILibraryManager libraryManager, + ILogger<MusicGenresValidator> logger, + IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs index 710e5d043..1ecf4c87c 100644 --- a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs @@ -7,20 +7,29 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { - class MusicGenresValidator + /// <summary> + /// Class MusicGenresValidator. + /// </summary> + public class MusicGenresValidator { /// <summary> - /// The _library manager + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; /// <summary> - /// The _logger + /// The logger. /// </summary> - private readonly ILogger _logger; + private readonly ILogger<MusicGenresValidator> _logger; private readonly IItemRepository _itemRepo; - public MusicGenresValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <summary> + /// Initializes a new instance of the <see cref="MusicGenresValidator" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="itemRepo">The item repository.</param> + public MusicGenresValidator(ILibraryManager libraryManager, ILogger<MusicGenresValidator> logger, IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index 7899cf01b..8739a9e1b 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -1,7 +1,7 @@ using System; +using System.Globalization; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -11,16 +11,17 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { /// <summary> - /// Class PeopleValidator + /// Class PeopleValidator. /// </summary> public class PeopleValidator { /// <summary> - /// The _library manager + /// The _library manager. /// </summary> private readonly ILibraryManager _libraryManager; + /// <summary> - /// The _logger + /// The _logger. /// </summary> private readonly ILogger _logger; @@ -31,6 +32,7 @@ namespace Emby.Server.Implementations.Library.Validators /// </summary> /// <param name="libraryManager">The library manager.</param> /// <param name="logger">The logger.</param> + /// <param name="fileSystem">The file system.</param> public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem) { _libraryManager = libraryManager; @@ -62,7 +64,7 @@ namespace Emby.Server.Implementations.Library.Validators { var item = _libraryManager.GetPerson(person); - var options = new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ImageRefreshMode = MetadataRefreshMode.ValidationOnly, MetadataRefreshMode = MetadataRefreshMode.ValidationOnly @@ -89,19 +91,26 @@ 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 }); foreach (var item in deadEntities) { - _logger.LogInformation("Deleting dead {2} {0} {1}.", item.Id.ToString("N"), item.Name, item.GetType().Name); - - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false - }, false); + _logger.LogInformation( + "Deleting dead {2} {0} {1}.", + item.Id.ToString("N", CultureInfo.InvariantCulture), + item.Name, + item.GetType().Name); + + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); } progress.Report(100); diff --git a/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs index 4aa5c7e72..c682b156b 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs @@ -8,23 +8,28 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { /// <summary> - /// Class MusicGenresPostScanTask + /// Class MusicGenresPostScanTask. /// </summary> public class StudiosPostScanTask : ILibraryPostScanTask { /// <summary> - /// The _library manager + /// The _library manager. /// </summary> private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + private readonly ILogger<StudiosValidator> _logger; private readonly IItemRepository _itemRepo; /// <summary> - /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class. + /// Initializes a new instance of the <see cref="StudiosPostScanTask" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> - public StudiosPostScanTask(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <param name="logger">The logger.</param> + /// <param name="itemRepo">The item repository.</param> + public StudiosPostScanTask( + ILibraryManager libraryManager, + ILogger<StudiosValidator> logger, + IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs index da4645a11..8577d722e 100644 --- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -8,20 +9,30 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Validators { - class StudiosValidator + /// <summary> + /// Class StudiosValidator. + /// </summary> + public class StudiosValidator { /// <summary> - /// The _library manager + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; private readonly IItemRepository _itemRepo; + /// <summary> - /// The _logger + /// The logger. /// </summary> - private readonly ILogger _logger; + private readonly ILogger<StudiosValidator> _logger; - public StudiosValidator(ILibraryManager libraryManager, ILogger logger, IItemRepository itemRepo) + /// <summary> + /// Initializes a new instance of the <see cref="StudiosValidator" /> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="logger">The logger.</param> + /// <param name="itemRepo">The item repository.</param> + public StudiosValidator(ILibraryManager libraryManager, ILogger<StudiosValidator> logger, IItemRepository itemRepo) { _libraryManager = libraryManager; _logger = logger; @@ -69,20 +80,22 @@ 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 }); foreach (var item in deadEntities) { - _logger.LogInformation("Deleting dead {2} {0} {1}.", item.Id.ToString("N"), item.Name, item.GetType().Name); - - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false + _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name); - }, false); + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false + }, + false); } progress.Report(100); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index dd636e6cd..6937cc097 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -1,7 +1,11 @@ +#pragma warning disable CS1591 + using System; using System.IO; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; @@ -13,15 +17,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public class DirectRecorder : IRecorder { private readonly ILogger _logger; - private readonly IHttpClient _httpClient; - private readonly IFileSystem _fileSystem; + private readonly IHttpClientFactory _httpClientFactory; private readonly IStreamHelper _streamHelper; - public DirectRecorder(ILogger logger, IHttpClient httpClient, IFileSystem fileSystem, IStreamHelper streamHelper) + public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) { _logger = logger; - _httpClient = httpClient; - _fileSystem = fileSystem; + _httpClientFactory = httpClientFactory; _streamHelper = streamHelper; } @@ -30,7 +32,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return targetFile; } - public Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { if (directStreamProvider != null) { @@ -42,57 +44,56 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - using (var output = _fileSystem.GetFileStream(targetFile, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + using (var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous)) { onStarted(); - _logger.LogInformation("Copying recording stream to file {0}", targetFile); + _logger.LogInformation("Copying recording to file {FilePath}", 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; - - await directStreamProvider.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + var linkedCancellationToken = cancellationTokenSource.Token; + + await using var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); + await _streamHelper.CopyToAsync( + fileStream, + output, + IODefaults.CopyToBufferSize, + 1000, + linkedCancellationToken).ConfigureAwait(false); } - _logger.LogInformation("Recording completed to file {0}", targetFile); + _logger.LogInformation("Recording completed: {FilePath}", targetFile); } private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) { - var httpRequestOptions = new HttpRequestOptions - { - Url = mediaSource.Path, - BufferContent = false, + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - // Some remote urls will expect a user agent to be supplied - UserAgent = "Emby/3.0", + _logger.LogInformation("Opened recording stream from tuner provider"); - // Shouldn't matter but may cause issues - EnableHttpCompression = false - }; + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - using (var response = await _httpClient.SendAsync(httpRequestOptions, "GET").ConfigureAwait(false)) - { - _logger.LogInformation("Opened recording stream from tuner provider"); - - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + await using var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); - using (var output = _fileSystem.GetFileStream(targetFile, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.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 fceb82ba1..980b42729 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -1,17 +1,24 @@ +#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.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; using Emby.Server.Implementations.Library; +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; @@ -23,29 +30,27 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Diagnostics; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Providers; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Reflection; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV { public class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable { + public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; + + private const int TunerDiscoveryDurationMs = 3000; + private readonly IServerApplicationHost _appHost; - private readonly ILogger _logger; - private readonly IHttpClient _httpClient; + private readonly ILogger<EmbyTV> _logger; + private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; - private readonly IJsonSerializer _jsonSerializer; private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; private readonly TimerManager _timerProvider; @@ -57,89 +62,104 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly ILibraryManager _libraryManager; private readonly IProviderManager _providerManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IProcessFactory _processFactory; - private readonly IAssemblyInfo _assemblyInfo; - private IMediaSourceManager _mediaSourceManager; - - public static EmbyTV Current; - - public event EventHandler<GenericEventArgs<TimerInfo>> TimerCreated; - public event EventHandler<GenericEventArgs<string>> TimerCancelled; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IStreamHelper _streamHelper; private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase); - private readonly IStreamHelper _streamHelper; + private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels = + new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase); + + private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); + + private bool _disposed = false; - public EmbyTV(IServerApplicationHost appHost, + public EmbyTV( + IServerApplicationHost appHost, IStreamHelper streamHelper, IMediaSourceManager mediaSourceManager, - IAssemblyInfo assemblyInfo, - ILogger logger, - IJsonSerializer jsonSerializer, - IHttpClient httpClient, + ILogger<EmbyTV> logger, + IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ILiveTvManager liveTvManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, - IMediaEncoder mediaEncoder, - IProcessFactory processFactory) + IMediaEncoder mediaEncoder) { Current = this; _appHost = appHost; _logger = logger; - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _config = config; _fileSystem = fileSystem; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _providerManager = providerManager; _mediaEncoder = mediaEncoder; - _processFactory = processFactory; _liveTvManager = (LiveTvManager)liveTvManager; - _jsonSerializer = jsonSerializer; - _assemblyInfo = assemblyInfo; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; - _seriesTimerProvider = new SeriesTimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "seriestimers")); - _timerProvider = new TimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "timers"), _logger); - _timerProvider.TimerFired += _timerProvider_TimerFired; + _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); + _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); + _timerProvider.TimerFired += OnTimerProviderTimerFired; - _config.NamedConfigurationUpdated += _config_NamedConfigurationUpdated; + _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; } - private void _config_NamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) + public event EventHandler<GenericEventArgs<TimerInfo>> TimerCreated; + + public event EventHandler<GenericEventArgs<string>> TimerCancelled; + + public static EmbyTV Current { get; private set; } + + /// <inheritdoc /> + public string Name => "Emby"; + + public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); + + /// <inheritdoc /> + public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; + + private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); + + private string RecordingPath { - if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) + get { - OnRecordingFoldersChanged(); + var path = GetConfiguration().RecordingPath; + + return string.IsNullOrWhiteSpace(path) + ? DefaultRecordingPath + : path; } } - public async Task Start() + private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) { - _timerProvider.RestartTimers(); - - await CreateRecordingFolders().ConfigureAwait(false); + if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) + { + await CreateRecordingFolders().ConfigureAwait(false); + } } - private async void OnRecordingFoldersChanged() + public Task Start() { - await CreateRecordingFolders().ConfigureAwait(false); + _timerProvider.RestartTimers(); + + return CreateRecordingFolders(); } internal async Task CreateRecordingFolders() { try { - var recordingFolders = GetRecordingFolders(); - - var virtualFolders = _libraryManager.GetVirtualFolders() - .ToList(); + var recordingFolders = GetRecordingFolders().ToArray(); + var virtualFolders = _libraryManager.GetVirtualFolders(); var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); @@ -156,7 +176,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV continue; } - var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo { Path = i }).ToArray(); + var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); var libraryOptions = new LibraryOptions { @@ -189,7 +209,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV foreach (var path in pathsToRemove) { - await RemovePathFromLibrary(path).ConfigureAwait(false); + await RemovePathFromLibraryAsync(path).ConfigureAwait(false); } } catch (Exception ex) @@ -198,13 +218,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private async Task RemovePathFromLibrary(string path) + private async Task RemovePathFromLibraryAsync(string path) { _logger.LogDebug("Removing path from library: {0}", path); var requiresRefresh = false; - var virtualFolders = _libraryManager.GetVirtualFolders() - .ToList(); + var virtualFolders = _libraryManager.GetVirtualFolders(); foreach (var virtualFolder in virtualFolders) { @@ -241,31 +260,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None); - } - } - - public string Name => "Emby"; - - public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); - - private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); - - private string RecordingPath - { - get - { - var path = GetConfiguration().RecordingPath; - - return string.IsNullOrWhiteSpace(path) - ? DefaultRecordingPath - : path; + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); } } - public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; - - public async Task RefreshSeriesTimers(CancellationToken cancellationToken, IProgress<double> progress) + public async Task RefreshSeriesTimers(CancellationToken cancellationToken) { var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); @@ -275,7 +274,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - public async Task RefreshTimers(CancellationToken cancellationToken, IProgress<double> progress) + public async Task RefreshTimers(CancellationToken cancellationToken) { var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); @@ -343,7 +342,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } catch (NotSupportedException) { - } catch (Exception ex) { @@ -355,7 +353,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return list; } - private async Task AddMetadata(IListingsProvider provider, ListingsProviderInfo info, List<ChannelInfo> tunerChannels, bool enableCache, CancellationToken cancellationToken) + private async Task AddMetadata( + IListingsProvider provider, + ListingsProviderInfo info, + IEnumerable<ChannelInfo> tunerChannels, + bool enableCache, + CancellationToken cancellationToken) { var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); @@ -367,8 +370,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { if (!string.IsNullOrWhiteSpace(epgChannel.Name)) { - //tunerChannel.Name = epgChannel.Name; + // tunerChannel.Name = epgChannel.Name; } + if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) { tunerChannel.ImageUrl = epgChannel.ImageUrl; @@ -377,10 +381,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels = - new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase); - - private async Task<EpgChannelData> GetEpgChannels(IListingsProvider provider, ListingsProviderInfo info, bool enableCache, CancellationToken cancellationToken) + private async Task<EpgChannelData> GetEpgChannels( + IListingsProvider provider, + ListingsProviderInfo info, + bool enableCache, + CancellationToken cancellationToken) { if (!enableCache || !_epgChannels.TryGetValue(info.Id, out var result)) { @@ -398,59 +403,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return result; } - private class EpgChannelData - { - public EpgChannelData(List<ChannelInfo> channels) - { - 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; - - if (!string.IsNullOrEmpty(channel.Number)) - { - ChannelsByNumber[channel.Number] = channel; - } - - var normalizedName = NormalizeName(channel.Name ?? string.Empty); - if (!string.IsNullOrWhiteSpace(normalizedName)) - { - ChannelsByName[normalizedName] = channel; - } - } - } - - private Dictionary<string, ChannelInfo> ChannelsById { get; set; } - private Dictionary<string, ChannelInfo> ChannelsByNumber { get; set; } - private Dictionary<string, ChannelInfo> ChannelsByName { get; set; } - - public ChannelInfo GetChannelById(string id) - { - ChannelInfo result = null; - - ChannelsById.TryGetValue(id, out 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; - } - } - private async Task<ChannelInfo> GetEpgChannelFromTunerChannel(IListingsProvider provider, ListingsProviderInfo info, ChannelInfo tunerChannel, CancellationToken cancellationToken) { var epgChannels = await GetEpgChannels(provider, info, true, cancellationToken).ConfigureAwait(false); @@ -462,11 +414,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { foreach (NameValuePair mapping in mappings) { - if (StringHelper.EqualsIgnoreCase(mapping.Name, channelId)) + if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase)) { return mapping.Value; } } + return channelId; } @@ -480,7 +433,10 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); } - private ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, EpgChannelData epgChannelData) + private ChannelInfo GetEpgChannelFromTunerChannel( + NameValuePair[] mappings, + ChannelInfo tunerChannel, + EpgChannelData epgChannelData) { if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) { @@ -502,7 +458,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) { var tunerChannelId = tunerChannel.TunerChannelId; - if (tunerChannelId.IndexOf(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase) != -1) + if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) { tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); } @@ -541,7 +497,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) { - var normalizedName = NormalizeName(tunerChannel.Name); + var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); var channel = epgChannelData.GetChannelByName(normalizedName); @@ -554,11 +510,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return null; } - private static string NormalizeName(string value) - { - return value.Replace(" ", string.Empty).Replace("-", string.Empty); - } - public async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) { var list = new List<ChannelInfo>(); @@ -604,6 +555,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { _seriesTimerProvider.Delete(remove); } + return Task.CompletedTask; } @@ -648,11 +600,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(); @@ -663,16 +610,16 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new NotImplementedException(); } - public Task<string> CreateTimer(TimerInfo timer, CancellationToken cancellationToken) + public Task<string> CreateTimer(TimerInfo info, CancellationToken cancellationToken) { - var existingTimer = string.IsNullOrWhiteSpace(timer.ProgramId) ? + var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? null : - _timerProvider.GetTimerByProgramId(timer.ProgramId); + _timerProvider.GetTimerByProgramId(info.ProgramId); if (existingTimer != null) { - if (existingTimer.Status == RecordingStatus.Cancelled || - existingTimer.Status == RecordingStatus.Completed) + if (existingTimer.Status == RecordingStatus.Cancelled + || existingTimer.Status == RecordingStatus.Completed) { existingTimer.Status = RecordingStatus.New; existingTimer.IsManual = true; @@ -685,39 +632,37 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - timer.Id = Guid.NewGuid().ToString("N"); + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); LiveTvProgram programInfo = null; - if (!string.IsNullOrWhiteSpace(timer.ProgramId)) + if (!string.IsNullOrWhiteSpace(info.ProgramId)) { - programInfo = GetProgramInfoFromCache(timer); + programInfo = GetProgramInfoFromCache(info); } + if (programInfo == null) { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); - programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); + programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); } if (programInfo != null) { - CopyProgramInfoToTimerInfo(programInfo, timer); + CopyProgramInfoToTimerInfo(programInfo, info); } - timer.IsManual = true; - _timerProvider.Add(timer); + info.IsManual = true; + _timerProvider.Add(info); - if (TimerCreated != null) - { - TimerCreated(this, new GenericEventArgs<TimerInfo>(timer)); - } + TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(info)); - return Task.FromResult(timer.Id); + return Task.FromResult(info.Id); } public async Task<string> CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) { - info.Id = Guid.NewGuid().ToString("N"); + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); // populate info.seriesID var program = GetProgramInfoFromCache(info.ProgramId); @@ -804,7 +749,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } // Only update if not currently active - if (!_activeRecordings.TryGetValue(updatedTimer.Id, out var activeRecordingInfo)) + if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) { existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; @@ -850,33 +795,31 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { return info.Path; } - return null; - } - public IEnumerable<ActiveRecordingInfo> GetAllActiveRecordings() - { - return _activeRecordings.Values.Where(i => i.Timer.Status == RecordingStatus.InProgress && !i.CancellationTokenSource.IsCancellationRequested); + return null; } 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; } } + return null; } @@ -968,18 +911,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); - List<ProgramInfo> programs; - if (epgChannel == null) { _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - programs = new List<ProgramInfo>(); + continue; } - else - { - programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) + + List<ProgramInfo> programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) .ConfigureAwait(false)).ToList(); - } // Replace the value that came from the provider with a normalized value foreach (var program in programs) @@ -995,7 +934,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - return new List<ProgramInfo>(); + return Enumerable.Empty<ProgramInfo>(); } private List<Tuple<IListingsProvider, ListingsProviderInfo>> GetListingProviders() @@ -1058,23 +997,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") + "_" + mediaSource.Id; - - //if (mediaSource.DateLiveStreamOpened.HasValue && enableStreamSharing) - //{ - // var ticks = (DateTime.UtcNow - mediaSource.DateLiveStreamOpened.Value).Ticks - TimeSpan.FromSeconds(10).Ticks; - // ticks = Math.Max(0, ticks); - // mediaSource.Path += "?t=" + ticks.ToString(CultureInfo.InvariantCulture) + "&s=" + mediaSource.DateLiveStreamOpened.Value.Ticks.ToString(CultureInfo.InvariantCulture); - //} - - return mediaSource; - } - public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(channelId)) @@ -1095,7 +1017,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } catch (NotImplementedException) { - } } @@ -1106,7 +1027,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var stream = new MediaSourceInfo { - EncoderPath = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveRecordings/" + info.Id + "/stream", + EncoderPath = _appHost.GetLoopbackHttpApiUrl() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", EncoderProtocol = MediaProtocol.Http, Path = info.Path, Protocol = MediaProtocol.File, @@ -1122,7 +1043,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> @@ -1146,7 +1067,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return Task.CompletedTask; } - async void _timerProvider_TimerFired(object sender, GenericEventArgs<TimerInfo> e) + private async void OnTimerProviderTimerFired(object sender, GenericEventArgs<TimerInfo> e) { var timer = e.Argument; @@ -1181,7 +1102,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } catch (OperationCanceledException) { - } catch (Exception ex) { @@ -1225,7 +1145,10 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (timer.SeasonNumber.HasValue) { - folderName = string.Format("Season {0}", timer.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture)); + folderName = string.Format( + CultureInfo.InvariantCulture, + "Season {0}", + timer.SeasonNumber.Value); recordPath = Path.Combine(recordPath, folderName); } } @@ -1279,6 +1202,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { recordPath = Path.Combine(recordPath, "Sports"); } + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); } else @@ -1287,6 +1211,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { recordPath = Path.Combine(recordPath, "Other"); } + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); } @@ -1308,6 +1233,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { programInfo = GetProgramInfoFromCache(timer); } + if (programInfo == null) { _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); @@ -1319,9 +1245,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV CopyProgramInfoToTimerInfo(programInfo, timer); } - string seriesPath = null; var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); - var recordPath = GetRecordingPath(timer, remoteMetadata, out seriesPath); + var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); var recordingStatus = RecordingStatus.New; string liveStreamId = null; @@ -1330,19 +1255,20 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { - var allMediaSources = await _mediaSourceManager.GetPlayackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); + var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); var mediaStreamInfo = allMediaSources[0]; IDirectStreamProvider directStreamProvider = null; if (mediaStreamInfo.RequiresOpening) { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal(new LiveStreamRequest - { - ItemId = channelItem.Id, - OpenToken = mediaStreamInfo.OpenToken - - }, CancellationToken.None).ConfigureAwait(false); + var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( + new LiveStreamRequest + { + ItemId = channelItem.Id, + OpenToken = mediaStreamInfo.OpenToken + }, + CancellationToken.None).ConfigureAwait(false); mediaStreamInfo = liveStreamResponse.Item1.MediaSource; liveStreamId = mediaStreamInfo.LiveStreamId; @@ -1360,7 +1286,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); - _logger.LogInformation("Writing file to path: " + recordPath); + _logger.LogInformation("Writing file to: {Path}", recordPath); Action onStarted = async () => { @@ -1376,7 +1302,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV await CreateRecordingFolders().ConfigureAwait(false); TriggerRefresh(recordPath); - EnforceKeepUpTo(timer, seriesPath); + await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); }; await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); @@ -1416,12 +1342,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) { - const int retryIntervalSeconds = 60; - _logger.LogInformation("Retrying recording in {0} seconds.", retryIntervalSeconds); + const int RetryIntervalSeconds = 60; + _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); timer.Status = RecordingStatus.New; timer.PrePaddingSeconds = 0; - timer.StartDate = DateTime.UtcNow.AddSeconds(retryIntervalSeconds); + timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); timer.RetryCount++; _timerProvider.AddOrUpdate(timer); } @@ -1485,24 +1411,26 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private void TriggerRefresh(string path) { - _logger.LogInformation("Triggering refresh on {path}", path); + _logger.LogInformation("Triggering refresh on {Path}", path); var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); if (item != null) { - _logger.LogInformation("Refreshing recording parent {path}", item.Path); + _logger.LogInformation("Refreshing recording parent {Path}", item.Path); - _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - RefreshPaths = new string[] + _providerManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - path, - Path.GetDirectoryName(path), - Path.GetDirectoryName(Path.GetDirectoryName(path)) - } - - }, RefreshPriority.High); + RefreshPaths = new string[] + { + path, + Path.GetDirectoryName(path), + Path.GetDirectoryName(Path.GetDirectoryName(path)) + } + }, + RefreshPriority.High); } } @@ -1524,7 +1452,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) { var parentItem = item.GetParent(); - if (parentItem != null && !(parentItem is AggregateFolder)) + if (parentItem != null && parentItem is not AggregateFolder) { item = parentItem; } @@ -1534,12 +1462,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return item; } - private async void EnforceKeepUpTo(TimerInfo timer, string seriesPath) + private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) { if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) { return; } + if (string.IsNullOrWhiteSpace(seriesPath)) { return; @@ -1577,22 +1506,20 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV DeleteLibraryItemsForTimers(timersToDelete); - var librarySeries = _libraryManager.FindByPath(seriesPath, true) as Folder; - - if (librarySeries == null) + if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) { return; } - var episodesToDelete = (librarySeries.GetItemList(new InternalItemsQuery - { - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DateCreated, SortOrder.Descending) }, - IsVirtualItem = false, - IsFolder = false, - Recursive = true, - DtoOptions = new DtoOptions(true) - - })) + var episodesToDelete = librarySeries.GetItemList( + new InternalItemsQuery + { + OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, + IsVirtualItem = false, + IsFolder = false, + Recursive = true, + DtoOptions = new DtoOptions(true) + }) .Where(i => i.IsFileProtocol && File.Exists(i.Path)) .Skip(seriesTimer.KeepUpTo - 1) .ToList(); @@ -1601,11 +1528,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { try { - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = true - - }, true); + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); } catch (Exception ex) { @@ -1619,7 +1548,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); private void DeleteLibraryItemsForTimers(List<TimerInfo> timers) { foreach (var timer in timers) @@ -1646,22 +1574,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (libraryItem != null) { - _libraryManager.DeleteItem(libraryItem, new DeleteOptions - { - DeleteFileLocation = true - - }, true); + _libraryManager.DeleteItem( + libraryItem, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); } - else + else if (File.Exists(timer.RecordingPath)) { - try - { - _fileSystem.DeleteFile(timer.RecordingPath); - } - catch (IOException) - { - - } + _fileSystem.DeleteFile(timer.RecordingPath); } _timerProvider.Delete(timer); @@ -1692,26 +1615,18 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return true; } - var hasRecordingAtPath = _activeRecordings - .Values - .ToList() - .Any(i => string.Equals(i.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); - - if (hasRecordingAtPath) - { - return true; - } - return false; + return _activeRecordings + .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, _fileSystem, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, _processFactory, _config); + return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); } - return new DirectRecorder(_logger, _httpClient, _fileSystem, _streamHelper); + return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); } private void OnSuccessfulRecording(TimerInfo timer, string path) @@ -1729,20 +1644,23 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV try { - var process = _processFactory.Create(new ProcessOptions + var process = new Process { - Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), - CreateNoWindow = true, - EnableRaisingEvents = true, - ErrorDialog = false, - FileName = options.RecordingPostProcessor, - IsHidden = true, - UseShellExecute = false - }); + StartInfo = new ProcessStartInfo + { + Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), + CreateNoWindow = true, + ErrorDialog = false, + FileName = options.RecordingPostProcessor, + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false + }, + EnableRaisingEvents = true + }; _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - process.Exited += Process_Exited; + process.Exited += OnProcessExited; process.Start(); } catch (Exception ex) @@ -1756,19 +1674,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); } - private void Process_Exited(object sender, EventArgs e) + private void OnProcessExited(object sender, EventArgs e) { - var process = (IProcess)sender; - try + using (var process = (Process)sender) { _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); } - catch - { - - } - - process.Dispose(); } private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image) @@ -1778,44 +1689,16 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false); } - string imageSaveFilenameWithoutExtension = null; - - switch (image.Type) + string imageSaveFilenameWithoutExtension = image.Type switch { - case ImageType.Primary: - - if (program.IsSeries) - { - imageSaveFilenameWithoutExtension = Path.GetFileNameWithoutExtension(recordingPath) + "-thumb"; - } - else - { - imageSaveFilenameWithoutExtension = "poster"; - } - - break; - case ImageType.Logo: - imageSaveFilenameWithoutExtension = "logo"; - break; - case ImageType.Thumb: - if (program.IsSeries) - { - imageSaveFilenameWithoutExtension = Path.GetFileNameWithoutExtension(recordingPath) + "-thumb"; - } - else - { - imageSaveFilenameWithoutExtension = "landscape"; - } - - break; - case ImageType.Backdrop: - imageSaveFilenameWithoutExtension = "fanart"; - break; - default: - break; - } + ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster", + ImageType.Logo => "logo", + ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape", + ImageType.Backdrop => "fanart", + _ => null + }; - if (string.IsNullOrWhiteSpace(imageSaveFilenameWithoutExtension)) + if (imageSaveFilenameWithoutExtension == null) { return; } @@ -1895,11 +1778,10 @@ 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) - }).FirstOrDefault() as LiveTvProgram; // dummy this up @@ -1923,11 +1805,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { program.AddGenre("Sports"); } + if (timer.IsKids) { program.AddGenre("Kids"); program.AddGenre("Children"); } + if (timer.IsNews) { program.AddGenre("News"); @@ -1964,13 +1848,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { Indent = true, - Encoding = Encoding.UTF8, - CloseOutput = false + Encoding = Encoding.UTF8 }; using (var writer = XmlWriter.Create(stream, settings)) @@ -1978,19 +1861,22 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV writer.WriteStartDocument(true); writer.WriteStartElement("tvshow"); string id; - if (timer.SeriesProviderIds.TryGetValue(MetadataProviders.Tvdb.ToString(), out id)) + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out id)) { writer.WriteElementString("id", id); } - if (timer.SeriesProviderIds.TryGetValue(MetadataProviders.Imdb.ToString(), out id)) + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id)) { writer.WriteElementString("imdb_id", id); } - if (timer.SeriesProviderIds.TryGetValue(MetadataProviders.Tmdb.ToString(), out id)) + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id)) { writer.WriteElementString("tmdbid", id); } - if (timer.SeriesProviderIds.TryGetValue(MetadataProviders.Zap2It.ToString(), out id)) + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id)) { writer.WriteElementString("zap2itid", id); } @@ -2016,7 +1902,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; private void SaveVideoNfo(TimerInfo timer, string recordingPath, BaseItem item, bool lockData) { var nfoPath = Path.ChangeExtension(recordingPath, ".nfo"); @@ -2026,13 +1911,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { Indent = true, - Encoding = Encoding.UTF8, - CloseOutput = false + Encoding = Encoding.UTF8 }; var options = _config.GetNfoConfiguration(); @@ -2058,7 +1942,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var formatString = options.ReleaseDateFormat; - writer.WriteElementString("aired", premiereDate.Value.ToLocalTime().ToString(formatString)); + writer.WriteElementString( + "aired", + premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)); } if (item.IndexNumber.HasValue) @@ -2089,12 +1975,18 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var formatString = options.ReleaseDateFormat; - writer.WriteElementString("premiered", item.PremiereDate.Value.ToLocalTime().ToString(formatString)); - writer.WriteElementString("releasedate", item.PremiereDate.Value.ToLocalTime().ToString(formatString)); + writer.WriteElementString( + "premiered", + item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)); + writer.WriteElementString( + "releasedate", + item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)); } } - writer.WriteElementString("dateadded", DateTime.UtcNow.ToLocalTime().ToString(DateAddedFormat)); + writer.WriteElementString( + "dateadded", + DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)); if (item.ProductionYear.HasValue) { @@ -2108,7 +2000,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var overview = (item.Overview ?? string.Empty) .StripHtml() - .Replace(""", "'"); + .Replace(""", "'", StringComparison.Ordinal); writer.WriteElementString("plot", overview); @@ -2150,14 +2042,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV writer.WriteElementString("credits", person); } - var tmdbCollection = item.GetProviderId(MetadataProviders.TmdbCollection); + var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); if (!string.IsNullOrEmpty(tmdbCollection)) { writer.WriteElementString("collectionnumber", tmdbCollection); } - var imdb = item.GetProviderId(MetadataProviders.Imdb); + var imdb = item.GetProviderId(MetadataProvider.Imdb); if (!string.IsNullOrEmpty(imdb)) { if (!isSeriesEpisode) @@ -2171,7 +2063,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV lockData = false; } - var tvdb = item.GetProviderId(MetadataProviders.Tvdb); + var tvdb = item.GetProviderId(MetadataProvider.Tvdb); if (!string.IsNullOrEmpty(tvdb)) { writer.WriteElementString("tvdbid", tvdb); @@ -2180,7 +2072,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV lockData = false; } - var tmdb = item.GetProviderId(MetadataProviders.Tmdb); + var tmdb = item.GetProviderId(MetadataProvider.Tmdb); if (!string.IsNullOrEmpty(tmdb)) { writer.WriteElementString("tmdbid", tmdb); @@ -2216,17 +2108,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } private static bool IsPersonType(PersonInfo person, string type) - { - return string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase); - } - - private void AddGenre(List<string> genres, string genre) - { - if (!genres.Contains(genre, StringComparer.OrdinalIgnoreCase)) - { - genres.Add(genre); - } - } + => string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) + || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase); private LiveTvProgram GetProgramInfoFromCache(string programId) { @@ -2254,7 +2137,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) { @@ -2262,7 +2145,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV }, MinStartDate = startDateUtc.AddMinutes(-3), MaxStartDate = startDateUtc.AddMinutes(3), - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.StartDate, SortOrder.Ascending) } + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } }; if (!string.IsNullOrWhiteSpace(channelId)) @@ -2285,25 +2168,19 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return false; } - if (!seriesTimer.RecordAnyTime) + if (!seriesTimer.RecordAnyTime + && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) { - if (Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) - { - return true; - } + return true; } - //if (!seriesTimer.Days.Contains(timer.StartDate.ToLocalTime().DayOfWeek)) - //{ - // return true; - //} - if (seriesTimer.RecordNewOnly && timer.IsRepeat) { return true; } - if (!seriesTimer.RecordAnyChannel && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) + if (!seriesTimer.RecordAnyChannel + && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) { return true; } @@ -2348,18 +2225,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { var allTimers = GetTimersForSeries(seriesTimer).ToList(); - 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) { @@ -2371,10 +2243,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { enabledTimersForSeries.Add(timer); } + _timerProvider.Add(timer); TimerCreated?.Invoke(this, new GenericEventArgs<TimerInfo>(timer)); } + // Only update if not currently active - test both new timer and existing in case Id's are different // Id's could be different if the timer was created manually prior to series timer creation else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) @@ -2478,7 +2352,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) { @@ -2510,13 +2384,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) { - channel = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name }, - ItemIds = new[] { parent.ChannelId }, - DtoOptions = new DtoOptions() - - }).Cast<LiveTvChannel>().FirstOrDefault(); + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, + ItemIds = new[] { parent.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; if (channel != null && !string.IsNullOrWhiteSpace(channel.ExternalId)) { @@ -2533,7 +2407,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var timer = new TimerInfo { ChannelId = channelId, - Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N"), + Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), StartDate = parent.StartDate, EndDate = parent.EndDate.Value, ProgramId = parent.ExternalId, @@ -2569,13 +2443,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) { - channel = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new string[] { typeof(LiveTvChannel).Name }, - ItemIds = new[] { programInfo.ChannelId }, - DtoOptions = new DtoOptions() - - }).Cast<LiveTvChannel>().FirstOrDefault(); + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new string[] { nameof(LiveTvChannel) }, + ItemIds = new[] { programInfo.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; if (channel != null && !string.IsNullOrWhiteSpace(channel.ExternalId)) { @@ -2620,10 +2494,10 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV foreach (var providerId in timerInfo.ProviderIds) { - var srch = "Series"; - if (providerId.Key.StartsWith(srch, StringComparison.OrdinalIgnoreCase)) + const string Search = "Series"; + if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) { - seriesProviderIds[providerId.Key.Substring(srch.Length)] = providerId.Value; + seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; } } @@ -2634,12 +2508,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) { - var seriesIds = _libraryManager.GetItemIds(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(Series).Name }, - Name = program.Name - - }).ToArray(); + var seriesIds = _libraryManager.GetItemIds( + new InternalItemsQuery + { + IncludeItemTypes = new[] { nameof(Series) }, + Name = program.Name + }).ToArray(); if (seriesIds.Length == 0) { @@ -2650,7 +2524,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, @@ -2668,59 +2542,70 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return false; } - private bool _disposed; + /// <inheritdoc /> public void Dispose() { - _disposed = true; + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _recordingDeleteSemaphore.Dispose(); + } + foreach (var pair in _activeRecordings.ToList()) { pair.Value.CancellationTokenSource.Cancel(); } + + _disposed = true; } - public List<VirtualFolderInfo> GetRecordingFolders() + public IEnumerable<VirtualFolderInfo> GetRecordingFolders() { - var list = new List<VirtualFolderInfo>(); - var defaultFolder = RecordingPath; var defaultName = "Recordings"; if (Directory.Exists(defaultFolder)) { - list.Add(new VirtualFolderInfo + yield return new VirtualFolderInfo { Locations = new string[] { defaultFolder }, Name = defaultName - }); + }; } var customPath = GetConfiguration().MovieRecordingPath; - if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && Directory.Exists(customPath)) + if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { - list.Add(new VirtualFolderInfo + yield return new VirtualFolderInfo { Locations = new string[] { customPath }, Name = "Recorded Movies", - CollectionType = CollectionType.Movies - }); + CollectionType = CollectionTypeOptions.Movies + }; } customPath = GetConfiguration().SeriesRecordingPath; - if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && Directory.Exists(customPath)) + if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { - list.Add(new VirtualFolderInfo + yield return new VirtualFolderInfo { Locations = new string[] { customPath }, Name = "Recorded Shows", - CollectionType = CollectionType.TvShows - }); + CollectionType = CollectionTypeOptions.TvShows + }; } - - return list; } - private const int TunerDiscoveryDurationMs = 3000; - public async Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) { var list = new List<TunerHostInfo>(); @@ -2739,6 +2624,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparer.OrdinalIgnoreCase)) .ToList(); } + list.AddRange(discoveredDevices); } @@ -2775,11 +2661,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private async Task<List<TunerHostInfo>> DiscoverDevices(ITunerHost host, int discoveryDuationMs, CancellationToken cancellationToken) + private async Task<List<TunerHostInfo>> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) { try { - var discoveredDevices = await host.DiscoverDevices(discoveryDuationMs, cancellationToken).ConfigureAwait(false); + var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); foreach (var device in discoveredDevices) { @@ -2796,11 +2682,4 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } } - public static class ConfigurationExtension - { - public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager manager) - { - return manager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata"); - } - } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index 9a9bae215..835028b92 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -1,22 +1,25 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; -using System.Linq; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.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.Diagnostics; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -24,34 +27,26 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public class EncodedRecorder : IRecorder { private readonly ILogger _logger; - private readonly IFileSystem _fileSystem; 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 IProcess _process; - private readonly IProcessFactory _processFactory; - private readonly IJsonSerializer _json; - private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); - private readonly IServerConfigurationManager _config; + private Process _process; public EncodedRecorder( ILogger logger, - IFileSystem fileSystem, IMediaEncoder mediaEncoder, IServerApplicationPaths appPaths, - IJsonSerializer json, - IProcessFactory processFactory, - IServerConfigurationManager config) + IServerConfigurationManager serverConfigurationManager) { _logger = logger; - _fileSystem = fileSystem; _mediaEncoder = mediaEncoder; _appPaths = appPaths; - _json = json; - _processFactory = processFactory; - _config = config; + _serverConfigurationManager = serverConfigurationManager; } private static bool CopySubtitles => false; @@ -64,73 +59,63 @@ 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)); - var process = _processFactory.Create(new ProcessOptions + var processStartInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, - // Must consume both stdout and stderr or deadlocks may occur - //RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile, duration), - IsHidden = true, - ErrorDialog = false, - EnableRaisingEvents = true - }); - - _process = process; + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }; - var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + var commandLineLogMessage = processStartInfo.FileName + " " + processStartInfo.Arguments; _logger.LogInformation(commandLineLogMessage); var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - _logFileStream = _fileSystem.GetFileStream(logFilePath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, true); + _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - 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.Exited += (sender, args) => OnFfMpegProcessExited(process, inputFile); + _process = new Process + { + StartInfo = processStartInfo, + EnableRaisingEvents = true + }; + _process.Exited += (sender, args) => OnFfMpegProcessExited(_process); - process.Start(); + _process.Start(); cancellationToken.Register(Stop); - // MUST read both stdout and stderr asynchronously or a deadlock may occurr - //process.BeginOutputReadLine(); - onStarted(); // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback - StartStreamingLog(process.StandardError.BaseStream, _logFileStream); + _ = 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) @@ -138,11 +123,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV string videoArgs; if (EncodeVideo(mediaSource)) { - var maxBitrate = 25000000; + const int MaxBitrate = 25000000; videoArgs = string.Format( - "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", - GetOutputSizeParam(), - maxBitrate.ToString(CultureInfo.InvariantCulture)); + CultureInfo.InvariantCulture, + "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", + GetOutputSizeParam(), + MaxBitrate); } else { @@ -151,18 +137,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV videoArgs += " -fflags +genpts"; - var durationParam = " -t " + _mediaEncoder.GetTimeParameter(duration.Ticks); - durationParam = string.Empty; - var flags = new List<string>(); if (mediaSource.IgnoreDts) { flags.Add("+igndts"); } + if (mediaSource.IgnoreIndex) { flags.Add("+ignidx"); } + if (mediaSource.GenPtsInput) { flags.Add("+genpts"); @@ -172,11 +157,9 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (flags.Count > 0) { - inputModifier += " -fflags " + string.Join("", flags.ToArray()); + inputModifier += " -fflags " + string.Join(string.Empty, flags); } - var videoStream = mediaSource.VideoStream; - if (mediaSource.ReadAtNativeFramerate) { inputModifier += " -re"; @@ -194,38 +177,38 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; - //var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? + // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? // " -f mp4 -movflags frag_keyframe+empty_moov" : // string.Empty; var outputParam = string.Empty; - var commandLineArgs = string.Format("-i \"{0}\"{5} {2} -map_metadata -1 -threads 0 {3}{4}{6} -y \"{1}\"", + var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); + var commandLineArgs = string.Format( + CultureInfo.InvariantCulture, + "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", inputTempFile, - targetFile, + targetFile.Replace("\"", "\\\""), // Escape quotes in filename videoArgs, GetAudioArgs(mediaSource), subtitleArgs, - durationParam, - outputParam); + outputParam, + threads); return inputModifier + " " + commandLineArgs; } private static string GetAudioArgs(MediaSourceInfo mediaSource) { - var mediaStreams = mediaSource.MediaStreams ?? new List<MediaStream>(); - var inputAudioCodec = mediaStreams.Where(i => i.Type == MediaStreamType.Audio).Select(i => i.Codec).FirstOrDefault() ?? string.Empty; - return "-codec:a:0 copy"; - //var audioChannels = 2; - //var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); - //if (audioStream != null) - //{ + // var audioChannels = 2; + // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); + // if (audioStream != null) + // { // audioChannels = audioStream.Channels ?? audioChannels; - //} - //return "-codec:a:0 aac -strict experimental -ab 320000"; + // } + // return "-codec:a:0 aac -strict experimental -ab 320000"; } private static bool EncodeVideo(MediaSourceInfo mediaSource) @@ -234,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(" -vf \"{0}\"", string.Join(",", filters.ToArray())); - } - - return output; - } + => "-vf \"yadif=0:-1:0\""; private void Stop() { @@ -257,7 +227,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { _logger.LogInformation("Stopping ffmpeg recording process for {path}", _targetPath); - //process.Kill(); _process.StandardInput.WriteLine("q"); } catch (Exception ex) @@ -305,17 +274,18 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV /// <summary> /// Processes the exited. /// </summary> - private void OnFfMpegProcessExited(IProcess process, string inputFile) + private void OnFfMpegProcessExited(Process process) { - _hasExited = true; + using (process) + { + _hasExited = true; - DisposeLogStream(); + _logFileStream?.Dispose(); + _logFileStream = null; - try - { var exitCode = process.ExitCode; - _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {path}", exitCode, _targetPath); + _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath); if (exitCode == 0) { @@ -323,55 +293,32 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } else { - _taskCompletionSource.TrySetException(new Exception(string.Format("Recording for {path} failed. Exit code {ExitCode}", _targetPath, exitCode))); - } - } - catch - { - _logger.LogError("FFMpeg recording exited with an error for {path}.", _targetPath); - _taskCompletionSource.TrySetException(new Exception(string.Format("Recording for {path} failed", _targetPath))); - } - } - - private void DisposeLogStream() - { - if (_logFileStream != null) - { - try - { - _logFileStream.Dispose(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error disposing recording log stream"); + _taskCompletionSource.TrySetException( + new Exception( + string.Format( + CultureInfo.InvariantCulture, + "Recording for {0} failed. Exit code {1}", + _targetPath, + exitCode))); } - - _logFileStream = null; } } - private async void StartStreamingLog(Stream source, Stream target) + private async Task StartStreamingLog(Stream source, Stream target) { try { 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); } } } - catch (ObjectDisposedException) - { - // TODO Investigate and properly fix. - // Don't spam the log. This doesn't seem to throw in windows, but sometimes under linux - } catch (Exception ex) { _logger.LogError(ex, "Error reading ffmpeg recording log"); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs index 9c9ba09f5..a2ec2df37 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs @@ -1,15 +1,19 @@ +#pragma warning disable CS1591 + using System.Threading.Tasks; using MediaBrowser.Controller.Plugins; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - public class EntryPoint : IServerEntryPoint + public sealed class EntryPoint : IServerEntryPoint { + /// <inheritdoc /> public Task RunAsync() { return EmbyTV.Current.Start(); } + /// <inheritdoc /> public void Dispose() { } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs new file mode 100644 index 000000000..20a8213a7 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs @@ -0,0 +1,54 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +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); + + foreach (var channel in channels) + { + _channelsById[channel.Id] = channel; + + if (!string.IsNullOrEmpty(channel.Number)) + { + _channelsByNumber[channel.Number] = channel; + } + + var normalizedName = NormalizeName(channel.Name ?? string.Empty); + if (!string.IsNullOrWhiteSpace(normalizedName)) + { + _channelsByName[normalizedName] = channel; + } + } + } + + public ChannelInfo? GetChannelById(string id) + => _channelsById.GetValueOrDefault(id); + + public ChannelInfo? GetChannelByNumber(string number) + => _channelsByNumber.GetValueOrDefault(number); + + public ChannelInfo? GetChannelByName(string name) + => _channelsByName.GetValueOrDefault(name); + + public static string NormalizeName(string value) + { + return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs index 6eced3050..7705132da 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Threading; using System.Threading.Tasks; @@ -11,7 +13,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV /// <summary> /// Records the specified media source. /// </summary> - Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); + /// <param name="directStreamProvider">The direct stream provider, or <c>null</c>.</param> + /// <param name="mediaSource">The media source.</param> + /// <param name="targetFile">The target file.</param> + /// <param name="duration">The duration to record.</param> + /// <param name="onStarted">An action to perform when recording starts.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A <see cref="Task"/> that represents the recording operation.</returns> + Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs index a2ac60b31..46979bfc5 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -1,9 +1,12 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; +using System.Text.Json; +using Jellyfin.Extensions.Json; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -11,72 +14,71 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public class ItemDataProvider<T> where T : class { - private readonly object _fileDataLock = new object(); - private List<T> _items; - private readonly IJsonSerializer _jsonSerializer; - protected readonly ILogger Logger; private readonly string _dataPath; - protected readonly Func<T, T, bool> EqualityComparer; + 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) + public ItemDataProvider( + ILogger logger, + string dataPath, + Func<T, T, bool> equalityComparer) { Logger = logger; _dataPath = dataPath; EqualityComparer = equalityComparer; - _jsonSerializer = jsonSerializer; } - public IReadOnlyList<T> GetAll() - { - lock (_fileDataLock) - { - if (_items == null) - { - Logger.LogInformation("Loading live tv data from {0}", _dataPath); - _items = GetItemsFromFile(_dataPath); - } - return _items.ToList(); - } - } + protected ILogger Logger { get; } - private List<T> GetItemsFromFile(string path) - { - var jsonFile = path + ".json"; + protected Func<T, T, bool> EqualityComparer { get; } - if (!File.Exists(jsonFile)) + [MemberNotNull(nameof(_items))] + private void EnsureLoaded() + { + if (_items != null) { - return new List<T>(); + return; } - try - { - return _jsonSerializer.DeserializeFromFile<List<T>>(jsonFile) ?? new List<T>(); - } - catch (IOException) + if (File.Exists(_dataPath)) { - } - catch (Exception ex) - { - Logger.LogError(ex, "Error deserializing {jsonFile}", jsonFile); + Logger.LogInformation("Loading live tv data from {Path}", _dataPath); + + try + { + var bytes = File.ReadAllBytes(_dataPath); + _items = JsonSerializer.Deserialize<T[]>(bytes, _jsonOptions); + if (_items == null) + { + Logger.LogError("Error deserializing {Path}, data was null", _dataPath); + _items = Array.Empty<T>(); + } + + return; + } + catch (JsonException ex) + { + Logger.LogError(ex, "Error deserializing {Path}", _dataPath); + } } - return new List<T>(); + _items = Array.Empty<T>(); } - private void UpdateList(List<T> newList) + private void SaveList() { - if (newList == null) - { - throw new ArgumentNullException(nameof(newList)); - } - - var file = _dataPath + ".json"; - Directory.CreateDirectory(Path.GetDirectoryName(file)); + Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); + var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); + File.WriteAllText(_dataPath, jsonString); + } + public IReadOnlyList<T> GetAll() + { lock (_fileDataLock) { - _jsonSerializer.SerializeToFile(newList, file); - _items = newList; + EnsureLoaded(); + return (T[])_items.Clone(); } } @@ -87,18 +89,20 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new ArgumentNullException(nameof(item)); } - var list = GetAll().ToList(); - - var index = list.FindIndex(i => EqualityComparer(i, item)); - - if (index == -1) + lock (_fileDataLock) { - throw new ArgumentException("item not found"); - } + EnsureLoaded(); - list[index] = item; + var index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + throw new ArgumentException("item not found"); + } - UpdateList(list); + _items[index] = item; + + SaveList(); + } } public virtual void Add(T item) @@ -108,37 +112,58 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new ArgumentNullException(nameof(item)); } - var list = GetAll().ToList(); - - if (list.Any(i => EqualityComparer(i, item))) + lock (_fileDataLock) { - throw new ArgumentException("item already exists"); - } + EnsureLoaded(); - list.Add(item); + if (_items.Any(i => EqualityComparer(i, item))) + { + throw new ArgumentException("item already exists", nameof(item)); + } + + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; - UpdateList(list); + SaveList(); + } } - public void AddOrUpdate(T item) + public virtual void AddOrUpdate(T item) { - var list = GetAll().ToList(); - - if (!list.Any(i => EqualityComparer(i, item))) - { - Add(item); - } - else + lock (_fileDataLock) { - Update(item); + EnsureLoaded(); + + int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; + } + else + { + _items[index] = item; + } + + SaveList(); } } public virtual void Delete(T item) { - var list = GetAll().Where(i => !EqualityComparer(i, item)).ToList(); + lock (_fileDataLock) + { + EnsureLoaded(); + _items = _items.Where(i => !EqualityComparer(i, item)).ToArray(); - UpdateList(list); + SaveList(); + } } } } diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs new file mode 100644 index 000000000..83f5e8413 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs @@ -0,0 +1,19 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + /// <summary> + /// Class containing extension methods for working with the nfo configuration. + /// </summary> + public static class NfoConfigurationExtensions + { + /// <summary> + /// Gets the nfo configuration. + /// </summary> + /// <param name="configurationManager">The configuration manager.</param> + /// <returns>The nfo configuration.</returns> + public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager) + => configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata"); + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs index ded3c7607..32245f899 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs @@ -1,10 +1,12 @@ +#pragma warning disable CS1591 + using System; using System.Globalization; using MediaBrowser.Controller.LiveTv; namespace Emby.Server.Implementations.LiveTv.EmbyTV { - internal class RecordingHelper + internal static class RecordingHelper { public static DateTime GetStartTime(TimerInfo timer) { @@ -21,7 +23,11 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) { - name += string.Format(" S{0}E{1}", info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); + name += string.Format( + CultureInfo.InvariantCulture, + " S{0}E{1}", + info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), + info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); addHyphen = false; } else if (info.OriginalAirDate.HasValue) @@ -32,7 +38,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } else { - name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd"); + name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } } else @@ -50,7 +56,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV name += " " + info.EpisodeTitle; } } - else if (info.IsMovie && info.ProductionYear != null) { name += " (" + info.ProductionYear + ")"; @@ -65,16 +70,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private static string GetDateString(DateTime date) { - date = date.ToLocalTime(); - - return string.Format("{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 520b44404..b1259de23 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs @@ -1,17 +1,19 @@ +#pragma warning disable CS1591 + 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)) { } + /// <inheritdoc /> public override void Add(SeriesTimerInfo item) { if (string.IsNullOrEmpty(item.Id)) diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs index 3c807a8ea..a861e6ae4 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -1,12 +1,13 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Concurrent; using System.Globalization; using System.Linq; using System.Threading; +using Jellyfin.Data.Events; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Events; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV @@ -14,21 +15,19 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV public class TimerManager : ItemDataProvider<TimerInfo> { private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase); - private readonly ILogger _logger; - - public event EventHandler<GenericEventArgs<TimerInfo>> TimerFired; - public TimerManager(IJsonSerializer jsonSerializer, ILogger logger, string dataPath, ILogger logger1) - : 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)) { - _logger = logger1; } + public event EventHandler<GenericEventArgs<TimerInfo>>? TimerFired; + public void RestartTimers() { StopTimers(); - foreach (var item in GetAll().ToList()) + foreach (var item in GetAll()) { AddOrUpdateSystemTimer(item); } @@ -64,16 +63,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - var list = GetAll().ToList(); + base.AddOrUpdate(item); + } - if (!list.Any(i => EqualityComparer(i, item))) - { - base.Add(item); - } - else - { - base.Update(item); - } + public override void AddOrUpdate(TimerInfo item) + { + base.AddOrUpdate(item); + AddOrUpdateSystemTimer(item); } public override void Add(TimerInfo item) @@ -89,8 +85,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private static bool ShouldStartTimer(TimerInfo item) { - if (item.Status == RecordingStatus.Completed || - item.Status == RecordingStatus.Cancelled) + if (item.Status == RecordingStatus.Completed + || item.Status == RecordingStatus.Cancelled) { return false; } @@ -112,7 +108,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (startDate < now) { - TimerFired?.Invoke(this, new GenericEventArgs<TimerInfo> { Argument = item }); + TimerFired?.Invoke(this, new GenericEventArgs<TimerInfo>(item)); return; } @@ -126,12 +122,16 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (_timers.TryAdd(item.Id, timer)) { - _logger.LogInformation("Creating recording timer for {id}, {name}. Timer will fire in {minutes} minutes", item.Id, item.Name, dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture)); + Logger.LogInformation( + "Creating recording timer for {Id}, {Name}. Timer will fire in {Minutes} minutes", + item.Id, + item.Name, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture)); } else { timer.Dispose(); - _logger.LogWarning("Timer already exists for item {id}", item.Id); + Logger.LogWarning("Timer already exists for item {Id}", item.Id); } } @@ -143,23 +143,23 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } } - private void TimerCallback(object state) + private void TimerCallback(object? state) { - var timerId = (string)state; + var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); if (timer != null) { - TimerFired?.Invoke(this, new GenericEventArgs<TimerInfo> { Argument = timer }); + TimerFired?.Invoke(this, new GenericEventArgs<TimerInfo>(timer)); } } - public TimerInfo GetTimer(string id) + public TimerInfo? GetTimer(string id) { return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); } - public TimerInfo GetTimerByProgramId(string programId) + public TimerInfo? GetTimerByProgramId(string programId) { return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 0bbffb824..1f963e4a2 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -1,44 +1,61 @@ +#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.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; +using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; +using Jellyfin.Extensions; +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.Extensions; 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 readonly ILogger _logger; - private readonly IJsonSerializer _jsonSerializer; - private readonly IHttpClient _httpClient; + private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + + private readonly ILogger<SchedulesDirect> _logger; + 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 logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IApplicationHost appHost) + public SchedulesDirect( + ILogger<SchedulesDirect> logger, + IHttpClientFactory httpClientFactory, + ICryptoProvider cryptoProvider) { _logger = logger; - _jsonSerializer = jsonSerializer; - _httpClient = httpClient; - _appHost = appHost; + _httpClientFactory = httpClientFactory; + _cryptoProvider = cryptoProvider; } - private string UserAgent => _appHost.ApplicationUserAgent; + /// <inheritdoc /> + public string Name => "Schedules Direct"; + + /// <inheritdoc /> + public string Type => nameof(SchedulesDirect); private static List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc) { @@ -49,7 +66,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); } @@ -78,173 +95,170 @@ namespace Emby.Server.Implementations.LiveTv.Listings var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); - var requestList = new List<ScheduleDirect.RequestScheduleForChannel>() + var requestList = new List<RequestScheduleForChannelDto>() { - new ScheduleDirect.RequestScheduleForChannel() + new RequestScheduleForChannelDto() { - stationID = channelId, - date = dates + StationId = channelId, + Date = dates } }; - var requestString = _jsonSerializer.SerializeToString(requestList); + var requestString = JsonSerializer.Serialize(requestList, _jsonOptions); _logger.LogDebug("Request string for schedules is: {RequestString}", requestString); - var httpOptions = new HttpRequestOptions() + 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<IReadOnlyList<DayDto>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (dailySchedules == null) { - Url = ApiUrl + "/schedules", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - // The data can be large so give it some extra time - TimeoutMs = 60000, - LogErrorResponseBody = true, - RequestContent = requestString - }; - - httpOptions.RequestHeaders["token"] = token; - - using (var response = await Post(httpOptions, true, info).ConfigureAwait(false)) - using (var reader = new StreamReader(response.Content)) - { - var dailySchedules = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Day>>(response.Content).ConfigureAwait(false); - _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); + return Array.Empty<ProgramInfo>(); + } - httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/programs", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - // The data can be large so give it some extra time - TimeoutMs = 60000 - }; + _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); - httpOptions.RequestHeaders["token"] = token; + using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); + programRequestOptions.Headers.TryAddWithoutValidation("token", token); - var programsID = dailySchedules.SelectMany(d => d.programs.Select(s => s.programID)).Distinct(); - httpOptions.RequestContent = "[\"" + string.Join("\", \"", programsID) + "\"]"; + var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); + programRequestOptions.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(programIds, _jsonOptions)); + programRequestOptions.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); - using (var innerResponse = await Post(httpOptions, true, info).ConfigureAwait(false)) - using (var innerReader = new StreamReader(innerResponse.Content)) - { - var programDetails = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ProgramDetails>>(innerResponse.Content).ConfigureAwait(false); - var programDict = programDetails.ToDictionary(p => p.programID, y => y); + 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<IReadOnlyList<ProgramDetailsDto>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (programDetails == null) + { + return Array.Empty<ProgramInfo>(); + } - var programIdsWithImages = - programDetails.Where(p => p.hasImageArtwork).Select(p => p.programID) - .ToList(); + var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); - var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); + var programIdsWithImages = programDetails + .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) + .ToList(); - 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 images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); - if (images != null) - { - var imageIndex = images.FindIndex(i => i.programID == schedule.programID.Substring(0, 10)); - if (imageIndex > -1) - { - var programEntry = programDict[schedule.programID]; + var programsInfo = new List<ProgramInfo>(); + foreach (ProgramDto 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 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)); + if (string.IsNullOrEmpty(schedule.ProgramId)) + { + continue; + } - const double desiredAspect = 0.666666667; + if (images != null) + { + var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); + if (imageIndex > -1) + { + var programEntry = programDict[schedule.ProgramId]; - programEntry.primaryImage = GetProgramImage(ApiUrl, imagesWithText, true, desiredAspect) ?? - GetProgramImage(ApiUrl, allImages, true, desiredAspect); + var allImages = images[imageIndex].Data; + 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 wideAspect = 1.77777778; + const double DesiredAspect = 2.0 / 3; - programEntry.thumbImage = GetProgramImage(ApiUrl, imagesWithText, true, wideAspect); + programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, true, DesiredAspect) ?? + GetProgramImage(ApiUrl, allImages, true, DesiredAspect); - // Don't supply the same image twice - if (string.Equals(programEntry.primaryImage, programEntry.thumbImage, StringComparison.Ordinal)) - { - programEntry.thumbImage = null; - } + const double WideAspect = 16.0 / 9; - programEntry.backdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, wideAspect); + programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, 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); + + // 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); } - return programsInfo; } + + programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId])); } + + return programsInfo; } - private static int GetSizeOrder(ScheduleDirect.ImageData image) + private static int GetSizeOrder(ImageDataDto image) { - if (!string.IsNullOrWhiteSpace(image.height)) + if (int.TryParse(image.Height, out int value)) { - if (int.TryParse(image.height, out int value)) - { - return value; - } + return value; } return 0; } - private static string GetChannelNumber(ScheduleDirect.Map map) + private static string GetChannelNumber(MapDto map) { - var channelNumber = map.logicalChannelNumber; + var channelNumber = map.LogicalChannelNumber; if (string.IsNullOrWhiteSpace(channelNumber)) { - channelNumber = map.channel; + channelNumber = map.Channel; } + if (string.IsNullOrWhiteSpace(channelNumber)) { - channelNumber = map.atscMajor + "." + map.atscMinor; + channelNumber = map.AtscMajor + "." + map.AtscMinor; } return channelNumber.TrimStart('0'); } - private static bool IsMovie(ScheduleDirect.ProgramDetails programInfo) + private static bool IsMovie(ProgramDetailsDto programInfo) { - return string.Equals(programInfo.entityType, "movie", StringComparison.OrdinalIgnoreCase); + return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase); } - private ProgramInfo GetProgram(string channelId, ScheduleDirect.Program programInfo, ScheduleDirect.ProgramDetails details) + private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) { - var startAt = GetDate(programInfo.airDateTime); - var endAt = startAt.AddSeconds(programInfo.duration); + if (programInfo.AirDateTime == null) + { + return null; + } + + var startAt = programInfo.AirDateTime.Value; + var endAt = startAt.AddSeconds(programInfo.Duration); var audioType = ProgramAudio.Stereo; - var programId = programInfo.programID ?? string.Empty; + var programId = programInfo.ProgramId ?? string.Empty; string newID = programId + "T" + startAt.Ticks + "C" + channelId; - if (programInfo.audioProperties != null) + if (programInfo.AudioProperties.Count != 0) { - if (programInfo.audioProperties.Exists(item => string.Equals(item, "atmos", StringComparison.OrdinalIgnoreCase))) + if (programInfo.AudioProperties.Contains("atmos", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.Atmos; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd 5.1", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("dd", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "stereo", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("stereo", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.Stereo; } @@ -255,9 +269,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings } string episodeTitle = null; - if (details.episodeTitle150 != null) + if (details.EpisodeTitle150 != null) { - episodeTitle = details.episodeTitle150; + episodeTitle = details.EpisodeTitle150; } var info = new ProgramInfo @@ -266,22 +280,22 @@ 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, Audio = audioType, - //IsNew = programInfo.@new ?? false, - IsRepeat = programInfo.@new == null, - IsSeries = string.Equals(details.entityType, "episode", StringComparison.OrdinalIgnoreCase), - ImageUrl = details.primaryImage, - ThumbImageUrl = details.thumbImage, - IsKids = string.Equals(details.audience, "children", StringComparison.OrdinalIgnoreCase), - IsSports = string.Equals(details.entityType, "sports", StringComparison.OrdinalIgnoreCase), + // IsNew = programInfo.@new ?? false, + IsRepeat = programInfo.New == null, + IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase), + ImageUrl = details.PrimaryImage, + ThumbImageUrl = details.ThumbImage, + IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase), + IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase), IsMovie = IsMovie(details), - Etag = programInfo.md5, - IsLive = string.Equals(programInfo.liveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), - IsPremiere = programInfo.premiere || (programInfo.isPremiereOrFinale ?? string.Empty).IndexOf("premiere", StringComparison.OrdinalIgnoreCase) != -1 + Etag = programInfo.Md5, + IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), + IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).IndexOf("premiere", StringComparison.OrdinalIgnoreCase) != -1 }; var showId = programId; @@ -304,15 +318,16 @@ namespace Emby.Server.Implementations.LiveTv.Listings info.ShowId = showId; - if (programInfo.videoProperties != null) + if (programInfo.VideoProperties != null) { - info.IsHD = programInfo.videoProperties.Contains("hdtv", StringComparer.OrdinalIgnoreCase); - info.Is3D = programInfo.videoProperties.Contains("3d", StringComparer.OrdinalIgnoreCase); + info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparer.OrdinalIgnoreCase); + info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparer.OrdinalIgnoreCase); } - if (details.contentRating != null && details.contentRating.Count > 0) + 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)) @@ -321,15 +336,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - if (details.descriptions != null) + if (details.Descriptions != null) { - if (details.descriptions.description1000 != null && details.descriptions.description1000.Count > 0) + if (details.Descriptions.Description1000 != null && details.Descriptions.Description1000.Count > 0) { - info.Overview = details.descriptions.description1000[0].description; + info.Overview = details.Descriptions.Description1000[0].Description; } - else if (details.descriptions.description100 != null && details.descriptions.description100.Count > 0) + else if (details.Descriptions.Description100 != null && details.Descriptions.Description100.Count > 0) { - info.Overview = details.descriptions.description100[0].description; + info.Overview = details.Descriptions.Description100[0].Description; } } @@ -337,20 +352,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings { info.SeriesId = programId.Substring(0, 10); - info.SeriesProviderIds[MetadataProviders.Zap2It.ToString()] = info.SeriesId; + info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId; - if (details.metadata != null) + if (details.Metadata != null) { - foreach (var metadataProgram in details.metadata) + foreach (var metadataProgram in details.Metadata) { var gracenote = metadataProgram.Gracenote; if (gracenote != null) { - info.SeasonNumber = gracenote.season; + info.SeasonNumber = gracenote.Season; - if (gracenote.episode > 0) + if (gracenote.Episode > 0) { - info.EpisodeNumber = gracenote.episode; + info.EpisodeNumber = gracenote.Episode; } break; @@ -359,24 +374,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - if (!string.IsNullOrWhiteSpace(details.originalAirDate)) + if (details.OriginalAirDate != null) { - info.OriginalAirDate = DateTime.Parse(details.originalAirDate); + info.OriginalAirDate = details.OriginalAirDate; info.ProductionYear = info.OriginalAirDate.Value.Year; } - if (details.movie != null) + 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; } } - if (details.genres != null) + if (details.Genres != null) { - info.Genres = details.genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); - info.IsNews = details.genres.Contains("news", StringComparer.OrdinalIgnoreCase); + info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); + info.IsNews = details.Genres.Contains("news", StringComparer.OrdinalIgnoreCase); if (info.Genres.Contains("children", StringComparer.OrdinalIgnoreCase)) { @@ -387,22 +403,11 @@ namespace Emby.Server.Implementations.LiveTv.Listings return info; } - private static DateTime GetDate(string value) - { - var date = DateTime.ParseExact(value, "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture); - - if (date.Kind != DateTimeKind.Utc) - { - date = DateTime.SpecifyKind(date, DateTimeKind.Utc); - } - return date; - } - - private string GetProgramImage(string apiUrl, IEnumerable<ScheduleDirect.ImageData> images, bool returnDefaultImage, double desiredAspect) + private string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, bool returnDefaultImage, double desiredAspect) { var match = images .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) - .ThenByDescending(GetSizeOrder) + .ThenByDescending(i => GetSizeOrder(i)) .FirstOrDefault(); if (match == null) @@ -410,7 +415,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return null; } - var uri = match.uri; + var uri = match.Uri; if (string.IsNullOrWhiteSpace(uri)) { @@ -426,19 +431,19 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - private static double GetAspectRatio(ScheduleDirect.ImageData i) + private static double GetAspectRatio(ImageDataDto i) { int width = 0; int height = 0; - if (!string.IsNullOrWhiteSpace(i.width)) + if (!string.IsNullOrWhiteSpace(i.Width)) { - int.TryParse(i.width, out width); + _ = int.TryParse(i.Width, out width); } - if (!string.IsNullOrWhiteSpace(i.height)) + if (!string.IsNullOrWhiteSpace(i.Height)) { - int.TryParse(i.height, out height); + _ = int.TryParse(i.Height, out height); } if (height == 0 || width == 0) @@ -451,60 +456,50 @@ namespace Emby.Server.Implementations.LiveTv.Listings return result; } - private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms( + private async Task<IReadOnlyList<ShowImagesDto>> GetImageForPrograms( ListingsProviderInfo info, - List<string> programIds, - CancellationToken cancellationToken) + IReadOnlyList<string> programIds, + CancellationToken cancellationToken) { if (programIds.Count == 0) { - return new List<ScheduleDirect.ShowImages>(); + return Array.Empty<ShowImagesDto>(); } - 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)) - { - 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, - // The data can be large so give it some extra time - TimeoutMs = 60000 + 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<IReadOnlyList<ShowImagesDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error getting image info from schedules direct"); - return new List<ScheduleDirect.ShowImages>(); + return Array.Empty<ShowImagesDto>(); } } public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) { - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); var lineups = new List<NameIdPair>(); @@ -513,41 +508,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<IReadOnlyList<HeadendsDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (root != null) + if (root != null) + { + foreach (HeadendsDto headend in root) { - foreach (ScheduleDirect.Headends headend in root) + foreach (LineupDto 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?[18..] + }); } } - else - { - _logger.LogInformation("No lineups available"); - } + } + else + { + _logger.LogInformation("No lineups available"); } } catch (Exception ex) @@ -558,8 +545,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; @@ -582,8 +567,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); @@ -609,7 +593,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) { @@ -619,6 +603,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings _lastErrorResponse = DateTime.UtcNow; } } + throw; } finally @@ -627,112 +612,64 @@ 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.EnableHttpCompression = true; - - // On windows 7 under .net core, this header is not getting added -#if NETSTANDARD2_0 - if (Environment.OSVersion.Platform == PlatformID.Win32NT) + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { - options.RequestHeaders["Accept-Encoding"] = "deflate"; + return response; } -#endif - try + // Response is automatically disposed in the calling function, + // so dispose manually if not returning. + response.Dispose(); + if (!enableRetry || (int)response.StatusCode >= 500) { - return await _httpClient.Post(options).ConfigureAwait(false); + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); } - 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); + _tokens.Clear(); + options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + return await Send(options, false, providerInfo, cancellationToken).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.EnableHttpCompression = true; - - // On windows 7 under .net core, this header is not getting added -#if NETSTANDARD2_0 - if (Environment.OSVersion.Platform == PlatformID.Win32NT) - { - options.RequestHeaders["Accept-Encoding"] = "deflate"; - } -#endif - - try - { - return await _httpClient.SendAsync(options, "GET").ConfigureAwait(false); - } - 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 Get(options, false, providerInfo).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<TokenDto>(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: {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) { - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token)) { @@ -746,27 +683,11 @@ 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 (var response = await _httpClient.SendAsync(httpOptions, "PUT")) - { - } + 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); } - public string Name => "Schedules Direct"; - - public static string TypeName = "SchedulesDirect"; - public string Type => TypeName; - private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(info.ListingsId)) @@ -774,7 +695,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings throw new ArgumentException("Listings Id required"); } - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token)) { @@ -783,30 +704,23 @@ 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<LineupsDto>(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)) ?? false; } - catch (HttpException ex) + catch (HttpRequestException ex) { - // Apparently we're supposed to swallow this - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + // SchedulesDirect returns 400 if no lineups are configured. + if (ex.StatusCode is HttpStatusCode.BadRequest) { return false; } @@ -823,11 +737,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings { throw new ArgumentException("Username is required"); } + if (string.IsNullOrEmpty(info.Password)) { throw new ArgumentException("Password is required"); } } + if (validateListings) { if (string.IsNullOrEmpty(info.ListingsId)) @@ -857,388 +773,62 @@ namespace Emby.Server.Implementations.LiveTv.Listings throw new Exception("ListingsId required"); } - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token)) { throw new Exception("token required"); } - var httpOptions = new HttpRequestOptions() + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); + options.Headers.TryAddWithoutValidation("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<ChannelDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (root == null) { - Url = ApiUrl + "/lineups/" + listingsId, - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - // The data can be large so give it some extra time - TimeoutMs = 60000 - }; + return new List<ChannelInfo>(); + } - httpOptions.RequestHeaders["token"] = token; + _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; - 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 channelNumber = GetChannelNumber(channel); - var allStations = root.stations ?? Enumerable.Empty<ScheduleDirect.Station>(); + var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)); + var station = stationIndex == -1 + ? new StationDto { StationId = channel.StationId } + : allStations[stationIndex]; - foreach (ScheduleDirect.Map map in root.map) + var channelInfo = new ChannelInfo { - 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 - { - Id = station.stationID, - CallSign = station.callsign, - Number = channelNumber, - Name = string.IsNullOrWhiteSpace(station.name) ? channelNumber : station.name - }; - - 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)); + 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) { - return value.Replace(" ", string.Empty).Replace("-", string.Empty); - } - - public class ScheduleDirect - { - public class Token - { - public int code { get; set; } - public string message { get; set; } - public string serverID { get; set; } - public string token { get; set; } - } - public class Lineup - { - public string lineup { get; set; } - public string name { get; set; } - public string transport { get; set; } - public string location { get; set; } - public string uri { get; set; } - } - - public class Lineups - { - public int code { get; set; } - public string serverID { get; set; } - public string datetime { get; set; } - public List<Lineup> lineups { get; set; } - } - - - public class Headends - { - public string headend { get; set; } - public string transport { get; set; } - public string location { get; set; } - public List<Lineup> lineups { get; set; } - } - - - - public class Map - { - public string stationID { get; set; } - public string channel { get; set; } - public string logicalChannelNumber { get; set; } - public int uhfVhf { get; set; } - public int atscMajor { get; set; } - public int atscMinor { get; set; } - } - - public class Broadcaster - { - public string city { get; set; } - public string state { get; set; } - public string postalcode { get; set; } - public string country { get; set; } - } - - public class Logo - { - public string URL { get; set; } - public int height { get; set; } - public int width { get; set; } - public string md5 { get; set; } - } - - public class Station - { - public string stationID { get; set; } - public string name { get; set; } - public string callsign { get; set; } - public List<string> broadcastLanguage { get; set; } - public List<string> descriptionLanguage { get; set; } - public Broadcaster broadcaster { get; set; } - public string affiliate { get; set; } - public Logo logo { get; set; } - public bool? isCommercialFree { get; set; } - } - - public class Metadata - { - public string lineup { get; set; } - public string modified { get; set; } - public string transport { get; set; } - } - - public class Channel - { - public List<Map> map { get; set; } - public List<Station> stations { get; set; } - public Metadata metadata { get; set; } - } - - public class RequestScheduleForChannel - { - public string stationID { get; set; } - public List<string> date { get; set; } - } - - - - - public class Rating - { - public string body { get; set; } - public string code { get; set; } - } - - public class Multipart - { - public int partNumber { get; set; } - public int totalParts { get; set; } - } - - public class Program - { - public string programID { get; set; } - public string airDateTime { get; set; } - public int duration { get; set; } - public string md5 { get; set; } - public List<string> audioProperties { get; set; } - public List<string> videoProperties { get; set; } - public List<Rating> ratings { get; set; } - public bool? @new { get; set; } - public Multipart multipart { get; set; } - public string liveTapeDelay { get; set; } - public bool premiere { get; set; } - public bool repeat { get; set; } - public string isPremiereOrFinale { get; set; } - } - - - - public class MetadataSchedule - { - public string modified { get; set; } - public string md5 { get; set; } - public string startDate { get; set; } - public string endDate { get; set; } - public int days { get; set; } - } - - public class Day - { - public string stationID { get; set; } - public List<Program> programs { get; set; } - public MetadataSchedule metadata { get; set; } - - public Day() - { - programs = new List<Program>(); - } - } - - // - public class Title - { - public string title120 { get; set; } - } - - public class EventDetails - { - public string subType { get; set; } - } - - public class Description100 - { - public string descriptionLanguage { get; set; } - public string description { get; set; } - } - - public class Description1000 - { - public string descriptionLanguage { get; set; } - public string description { get; set; } - } - - public class DescriptionsProgram - { - public List<Description100> description100 { get; set; } - public List<Description1000> description1000 { get; set; } - } - - public class Gracenote - { - public int season { get; set; } - public int episode { get; set; } - } - - public class MetadataPrograms - { - public Gracenote Gracenote { get; set; } - } - - public class ContentRating - { - public string body { get; set; } - public string code { get; set; } - } - - public class Cast - { - public string billingOrder { get; set; } - public string role { get; set; } - public string nameId { get; set; } - public string personId { get; set; } - public string name { get; set; } - public string characterName { get; set; } - } - - public class Crew - { - public string billingOrder { get; set; } - public string role { get; set; } - public string nameId { get; set; } - public string personId { get; set; } - public string name { get; set; } - } - - public class QualityRating - { - public string ratingsBody { get; set; } - public string rating { get; set; } - public string minRating { get; set; } - public string maxRating { get; set; } - public string increment { get; set; } - } - - public class Movie - { - public string year { get; set; } - public int duration { get; set; } - public List<QualityRating> qualityRating { get; set; } - } - - public class Recommendation - { - public string programID { get; set; } - public string title120 { get; set; } - } - - public class ProgramDetails - { - public string audience { get; set; } - public string programID { get; set; } - public List<Title> titles { get; set; } - public EventDetails eventDetails { get; set; } - public DescriptionsProgram descriptions { get; set; } - public string originalAirDate { get; set; } - public List<string> genres { get; set; } - public string episodeTitle150 { get; set; } - public List<MetadataPrograms> metadata { get; set; } - public List<ContentRating> contentRating { get; set; } - public List<Cast> cast { get; set; } - public List<Crew> crew { get; set; } - public string entityType { get; set; } - public string showType { get; set; } - public bool hasImageArtwork { get; set; } - public string primaryImage { get; set; } - public string thumbImage { get; set; } - public string backdropImage { get; set; } - public string bannerImage { get; set; } - public string imageID { get; set; } - public string md5 { get; set; } - public List<string> contentAdvisory { get; set; } - public Movie movie { get; set; } - public List<Recommendation> recommendations { get; set; } - } - - public class Caption - { - public string content { get; set; } - public string lang { get; set; } - } - - public class ImageData - { - public string width { get; set; } - public string height { get; set; } - public string uri { get; set; } - public string size { get; set; } - public string aspect { get; set; } - public string category { get; set; } - public string text { get; set; } - public string primary { get; set; } - public string tier { get; set; } - public Caption caption { get; set; } - } - - public class ShowImages - { - public string programID { get; set; } - public List<ImageData> data { get; set; } - } - + return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); } } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs new file mode 100644 index 000000000..95ac996e0 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Broadcaster dto. + /// </summary> + public class BroadcasterDto + { + /// <summary> + /// Gets or sets the city. + /// </summary> + [JsonPropertyName("city")] + public string? City { get; set; } + + /// <summary> + /// Gets or sets the state. + /// </summary> + [JsonPropertyName("state")] + public string? State { get; set; } + + /// <summary> + /// Gets or sets the postal code. + /// </summary> + [JsonPropertyName("postalCode")] + public string? Postalcode { get; set; } + + /// <summary> + /// Gets or sets the country. + /// </summary> + [JsonPropertyName("country")] + public string? Country { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs new file mode 100644 index 000000000..f6251b9ad --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Caption dto. + /// </summary> + public class CaptionDto + { + /// <summary> + /// Gets or sets the content. + /// </summary> + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// <summary> + /// Gets or sets the lang. + /// </summary> + [JsonPropertyName("lang")] + public string? Lang { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs new file mode 100644 index 000000000..0b7a2c63a --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Cast dto. + /// </summary> + public class CastDto + { + /// <summary> + /// Gets or sets the billing order. + /// </summary> + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// <summary> + /// Gets or sets the role. + /// </summary> + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// <summary> + /// Gets or sets the name id. + /// </summary> + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// <summary> + /// Gets or sets the person id. + /// </summary> + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// <summary> + /// Gets or sets the name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the character name. + /// </summary> + [JsonPropertyName("characterName")] + public string? CharacterName { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs new file mode 100644 index 000000000..87c327ed8 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Channel dto. + /// </summary> + public class ChannelDto + { + /// <summary> + /// Gets or sets the list of maps. + /// </summary> + [JsonPropertyName("map")] + public IReadOnlyList<MapDto> Map { get; set; } = Array.Empty<MapDto>(); + + /// <summary> + /// Gets or sets the list of stations. + /// </summary> + [JsonPropertyName("stations")] + public IReadOnlyList<StationDto> Stations { get; set; } = Array.Empty<StationDto>(); + + /// <summary> + /// Gets or sets the metadata. + /// </summary> + [JsonPropertyName("metadata")] + public MetadataDto? Metadata { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs new file mode 100644 index 000000000..c19cd2e48 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Content rating dto. + /// </summary> + public class ContentRatingDto + { + /// <summary> + /// Gets or sets the body. + /// </summary> + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// <summary> + /// Gets or sets the code. + /// </summary> + [JsonPropertyName("code")] + public string? Code { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs new file mode 100644 index 000000000..f00c9accd --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Crew dto. + /// </summary> + public class CrewDto + { + /// <summary> + /// Gets or sets the billing order. + /// </summary> + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// <summary> + /// Gets or sets the role. + /// </summary> + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// <summary> + /// Gets or sets the name id. + /// </summary> + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// <summary> + /// Gets or sets the person id. + /// </summary> + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// <summary> + /// Gets or sets the name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs new file mode 100644 index 000000000..1a371965c --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Day dto. + /// </summary> + public class DayDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// <summary> + /// Gets or sets the list of programs. + /// </summary> + [JsonPropertyName("programs")] + public IReadOnlyList<ProgramDto> Programs { get; set; } = Array.Empty<ProgramDto>(); + + /// <summary> + /// Gets or sets the metadata schedule. + /// </summary> + [JsonPropertyName("metadata")] + public MetadataScheduleDto? Metadata { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs new file mode 100644 index 000000000..ca6ae7fb1 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Description 1_000 dto. + /// </summary> + public class Description1000Dto + { + /// <summary> + /// Gets or sets the description language. + /// </summary> + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// <summary> + /// Gets or sets the description. + /// </summary> + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs new file mode 100644 index 000000000..1577219ed --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Description 100 dto. + /// </summary> + public class Description100Dto + { + /// <summary> + /// Gets or sets the description language. + /// </summary> + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// <summary> + /// Gets or sets the description. + /// </summary> + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs new file mode 100644 index 000000000..eaf4a340b --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Descriptions program dto. + /// </summary> + public class DescriptionsProgramDto + { + /// <summary> + /// Gets or sets the list of description 100. + /// </summary> + [JsonPropertyName("description100")] + public IReadOnlyList<Description100Dto> Description100 { get; set; } = Array.Empty<Description100Dto>(); + + /// <summary> + /// Gets or sets the list of description1000. + /// </summary> + [JsonPropertyName("description1000")] + public IReadOnlyList<Description1000Dto> Description1000 { get; set; } = Array.Empty<Description1000Dto>(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs new file mode 100644 index 000000000..fbdfb1f71 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Event details dto. + /// </summary> + public class EventDetailsDto + { + /// <summary> + /// Gets or sets the sub type. + /// </summary> + [JsonPropertyName("subType")] + public string? SubType { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs new file mode 100644 index 000000000..6852d89d7 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Gracenote dto. + /// </summary> + public class GracenoteDto + { + /// <summary> + /// Gets or sets the season. + /// </summary> + [JsonPropertyName("season")] + public int Season { get; set; } + + /// <summary> + /// Gets or sets the episode. + /// </summary> + [JsonPropertyName("episode")] + public int Episode { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs new file mode 100644 index 000000000..b9844562f --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Headends dto. + /// </summary> + public class HeadendsDto + { + /// <summary> + /// Gets or sets the headend. + /// </summary> + [JsonPropertyName("headend")] + public string? Headend { get; set; } + + /// <summary> + /// Gets or sets the transport. + /// </summary> + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// <summary> + /// Gets or sets the location. + /// </summary> + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// <summary> + /// Gets or sets the list of lineups. + /// </summary> + [JsonPropertyName("lineups")] + public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs new file mode 100644 index 000000000..a1ae3ca6d --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Image data dto. + /// </summary> + public class ImageDataDto + { + /// <summary> + /// Gets or sets the width. + /// </summary> + [JsonPropertyName("width")] + public string? Width { get; set; } + + /// <summary> + /// Gets or sets the height. + /// </summary> + [JsonPropertyName("height")] + public string? Height { get; set; } + + /// <summary> + /// Gets or sets the uri. + /// </summary> + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + /// <summary> + /// Gets or sets the size. + /// </summary> + [JsonPropertyName("size")] + public string? Size { get; set; } + + /// <summary> + /// Gets or sets the aspect. + /// </summary> + [JsonPropertyName("aspect")] + public string? Aspect { get; set; } + + /// <summary> + /// Gets or sets the category. + /// </summary> + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// <summary> + /// Gets or sets the text. + /// </summary> + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// <summary> + /// Gets or sets the primary. + /// </summary> + [JsonPropertyName("primary")] + public string? Primary { get; set; } + + /// <summary> + /// Gets or sets the tier. + /// </summary> + [JsonPropertyName("tier")] + public string? Tier { get; set; } + + /// <summary> + /// Gets or sets the caption. + /// </summary> + [JsonPropertyName("caption")] + public CaptionDto? Caption { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs new file mode 100644 index 000000000..3dc64e5d8 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// The lineup dto. + /// </summary> + public class LineupDto + { + /// <summary> + /// Gets or sets the linup. + /// </summary> + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// <summary> + /// Gets or sets the lineup name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the transport. + /// </summary> + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// <summary> + /// Gets or sets the location. + /// </summary> + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// <summary> + /// Gets or sets the uri. + /// </summary> + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this lineup was deleted. + /// </summary> + [JsonPropertyName("isDeleted")] + public bool? IsDeleted { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs new file mode 100644 index 000000000..f19081781 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Lineups dto. + /// </summary> + public class LineupsDto + { + /// <summary> + /// Gets or sets the response code. + /// </summary> + [JsonPropertyName("code")] + public int Code { get; set; } + + /// <summary> + /// Gets or sets the server id. + /// </summary> + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// <summary> + /// Gets or sets the datetime. + /// </summary> + [JsonPropertyName("datetime")] + public DateTime? LineupTimestamp { get; set; } + + /// <summary> + /// Gets or sets the list of lineups. + /// </summary> + [JsonPropertyName("lineups")] + public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs new file mode 100644 index 000000000..fecc55e03 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Logo dto. + /// </summary> + public class LogoDto + { + /// <summary> + /// Gets or sets the url. + /// </summary> + [JsonPropertyName("URL")] + public string? Url { get; set; } + + /// <summary> + /// Gets or sets the height. + /// </summary> + [JsonPropertyName("height")] + public int Height { get; set; } + + /// <summary> + /// Gets or sets the width. + /// </summary> + [JsonPropertyName("width")] + public int Width { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs new file mode 100644 index 000000000..ffd02d474 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Map dto. + /// </summary> + public class MapDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// <summary> + /// Gets or sets the channel. + /// </summary> + [JsonPropertyName("channel")] + public string? Channel { get; set; } + + /// <summary> + /// Gets or sets the provider callsign. + /// </summary> + [JsonPropertyName("providerCallsign")] + public string? ProvderCallsign { get; set; } + + /// <summary> + /// Gets or sets the logical channel number. + /// </summary> + [JsonPropertyName("logicalChannelNumber")] + public string? LogicalChannelNumber { get; set; } + + /// <summary> + /// Gets or sets the uhfvhf. + /// </summary> + [JsonPropertyName("uhfVhf")] + public int UhfVhf { get; set; } + + /// <summary> + /// Gets or sets the atsc major. + /// </summary> + [JsonPropertyName("atscMajor")] + public int AtscMajor { get; set; } + + /// <summary> + /// Gets or sets the atsc minor. + /// </summary> + [JsonPropertyName("atscMinor")] + public int AtscMinor { get; set; } + + /// <summary> + /// Gets or sets the match type. + /// </summary> + [JsonPropertyName("matchType")] + public string? MatchType { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs new file mode 100644 index 000000000..40faa493c --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Metadata dto. + /// </summary> + public class MetadataDto + { + /// <summary> + /// Gets or sets the linup. + /// </summary> + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// <summary> + /// Gets or sets the modified timestamp. + /// </summary> + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// <summary> + /// Gets or sets the transport. + /// </summary> + [JsonPropertyName("transport")] + public string? Transport { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs new file mode 100644 index 000000000..43f290156 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Metadata programs dto. + /// </summary> + public class MetadataProgramsDto + { + /// <summary> + /// Gets or sets the gracenote object. + /// </summary> + [JsonPropertyName("Gracenote")] + public GracenoteDto? Gracenote { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs new file mode 100644 index 000000000..04560ab55 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs @@ -0,0 +1,41 @@ +using System; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Metadata schedule dto. + /// </summary> + public class MetadataScheduleDto + { + /// <summary> + /// Gets or sets the modified timestamp. + /// </summary> + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// <summary> + /// Gets or sets the start date. + /// </summary> + [JsonPropertyName("startDate")] + public DateTime? StartDate { get; set; } + + /// <summary> + /// Gets or sets the end date. + /// </summary> + [JsonPropertyName("endDate")] + public DateTime? EndDate { get; set; } + + /// <summary> + /// Gets or sets the days count. + /// </summary> + [JsonPropertyName("days")] + public int Days { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs new file mode 100644 index 000000000..31bef423b --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Movie dto. + /// </summary> + public class MovieDto + { + /// <summary> + /// Gets or sets the year. + /// </summary> + [JsonPropertyName("year")] + public string? Year { get; set; } + + /// <summary> + /// Gets or sets the duration. + /// </summary> + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// <summary> + /// Gets or sets the list of quality rating. + /// </summary> + [JsonPropertyName("qualityRating")] + public IReadOnlyList<QualityRatingDto> QualityRating { get; set; } = Array.Empty<QualityRatingDto>(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs new file mode 100644 index 000000000..e8b15dc07 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Multipart dto. + /// </summary> + public class MultipartDto + { + /// <summary> + /// Gets or sets the part number. + /// </summary> + [JsonPropertyName("partNumber")] + public int PartNumber { get; set; } + + /// <summary> + /// Gets or sets the total parts. + /// </summary> + [JsonPropertyName("totalParts")] + public int TotalParts { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs new file mode 100644 index 000000000..84c48f67f --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Program details dto. + /// </summary> + public class ProgramDetailsDto + { + /// <summary> + /// Gets or sets the audience. + /// </summary> + [JsonPropertyName("audience")] + public string? Audience { get; set; } + + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the list of titles. + /// </summary> + [JsonPropertyName("titles")] + public IReadOnlyList<TitleDto> Titles { get; set; } = Array.Empty<TitleDto>(); + + /// <summary> + /// Gets or sets the event details object. + /// </summary> + [JsonPropertyName("eventDetails")] + public EventDetailsDto? EventDetails { get; set; } + + /// <summary> + /// Gets or sets the descriptions. + /// </summary> + [JsonPropertyName("descriptions")] + public DescriptionsProgramDto? Descriptions { get; set; } + + /// <summary> + /// Gets or sets the original air date. + /// </summary> + [JsonPropertyName("originalAirDate")] + public DateTime? OriginalAirDate { get; set; } + + /// <summary> + /// Gets or sets the list of genres. + /// </summary> + [JsonPropertyName("genres")] + public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the episode title. + /// </summary> + [JsonPropertyName("episodeTitle150")] + public string? EpisodeTitle150 { get; set; } + + /// <summary> + /// Gets or sets the list of metadata. + /// </summary> + [JsonPropertyName("metadata")] + public IReadOnlyList<MetadataProgramsDto> Metadata { get; set; } = Array.Empty<MetadataProgramsDto>(); + + /// <summary> + /// Gets or sets the list of content raitings. + /// </summary> + [JsonPropertyName("contentRating")] + public IReadOnlyList<ContentRatingDto> ContentRating { get; set; } = Array.Empty<ContentRatingDto>(); + + /// <summary> + /// Gets or sets the list of cast. + /// </summary> + [JsonPropertyName("cast")] + public IReadOnlyList<CastDto> Cast { get; set; } = Array.Empty<CastDto>(); + + /// <summary> + /// Gets or sets the list of crew. + /// </summary> + [JsonPropertyName("crew")] + public IReadOnlyList<CrewDto> Crew { get; set; } = Array.Empty<CrewDto>(); + + /// <summary> + /// Gets or sets the entity type. + /// </summary> + [JsonPropertyName("entityType")] + public string? EntityType { get; set; } + + /// <summary> + /// Gets or sets the show type. + /// </summary> + [JsonPropertyName("showType")] + public string? ShowType { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether there is image artwork. + /// </summary> + [JsonPropertyName("hasImageArtwork")] + public bool HasImageArtwork { get; set; } + + /// <summary> + /// Gets or sets the primary image. + /// </summary> + [JsonPropertyName("primaryImage")] + public string? PrimaryImage { get; set; } + + /// <summary> + /// Gets or sets the thumb image. + /// </summary> + [JsonPropertyName("thumbImage")] + public string? ThumbImage { get; set; } + + /// <summary> + /// Gets or sets the backdrop image. + /// </summary> + [JsonPropertyName("backdropImage")] + public string? BackdropImage { get; set; } + + /// <summary> + /// Gets or sets the banner image. + /// </summary> + [JsonPropertyName("bannerImage")] + public string? BannerImage { get; set; } + + /// <summary> + /// Gets or sets the image id. + /// </summary> + [JsonPropertyName("imageID")] + public string? ImageId { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// <summary> + /// Gets or sets the list of content advisory. + /// </summary> + [JsonPropertyName("contentAdvisory")] + public IReadOnlyList<string> ContentAdvisory { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the movie object. + /// </summary> + [JsonPropertyName("movie")] + public MovieDto? Movie { get; set; } + + /// <summary> + /// Gets or sets the list of recommendations. + /// </summary> + [JsonPropertyName("recommendations")] + public IReadOnlyList<RecommendationDto> Recommendations { get; set; } = Array.Empty<RecommendationDto>(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs new file mode 100644 index 000000000..60389b45b --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Program dto. + /// </summary> + public class ProgramDto + { + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the air date time. + /// </summary> + [JsonPropertyName("airDateTime")] + public DateTime? AirDateTime { get; set; } + + /// <summary> + /// Gets or sets the duration. + /// </summary> + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// <summary> + /// Gets or sets the md5. + /// </summary> + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// <summary> + /// Gets or sets the list of audio properties. + /// </summary> + [JsonPropertyName("audioProperties")] + public IReadOnlyList<string> AudioProperties { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the list of video properties. + /// </summary> + [JsonPropertyName("videoProperties")] + public IReadOnlyList<string> VideoProperties { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the list of ratings. + /// </summary> + [JsonPropertyName("ratings")] + public IReadOnlyList<RatingDto> Ratings { get; set; } = Array.Empty<RatingDto>(); + + /// <summary> + /// Gets or sets a value indicating whether this program is new. + /// </summary> + [JsonPropertyName("new")] + public bool? New { get; set; } + + /// <summary> + /// Gets or sets the multipart object. + /// </summary> + [JsonPropertyName("multipart")] + public MultipartDto? Multipart { get; set; } + + /// <summary> + /// Gets or sets the live tape delay. + /// </summary> + [JsonPropertyName("liveTapeDelay")] + public string? LiveTapeDelay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this is the premiere. + /// </summary> + [JsonPropertyName("premiere")] + public bool Premiere { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this is a repeat. + /// </summary> + [JsonPropertyName("repeat")] + public bool Repeat { get; set; } + + /// <summary> + /// Gets or sets the premiere or finale. + /// </summary> + [JsonPropertyName("isPremiereOrFinale")] + public string? IsPremiereOrFinale { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs new file mode 100644 index 000000000..c5ddcf7c5 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Quality rating dto. + /// </summary> + public class QualityRatingDto + { + /// <summary> + /// Gets or sets the ratings body. + /// </summary> + [JsonPropertyName("ratingsBody")] + public string? RatingsBody { get; set; } + + /// <summary> + /// Gets or sets the rating. + /// </summary> + [JsonPropertyName("rating")] + public string? Rating { get; set; } + + /// <summary> + /// Gets or sets the min rating. + /// </summary> + [JsonPropertyName("minRating")] + public string? MinRating { get; set; } + + /// <summary> + /// Gets or sets the max rating. + /// </summary> + [JsonPropertyName("maxRating")] + public string? MaxRating { get; set; } + + /// <summary> + /// Gets or sets the increment. + /// </summary> + [JsonPropertyName("increment")] + public string? Increment { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs new file mode 100644 index 000000000..e04b619a4 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Rating dto. + /// </summary> + public class RatingDto + { + /// <summary> + /// Gets or sets the body. + /// </summary> + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// <summary> + /// Gets or sets the code. + /// </summary> + [JsonPropertyName("code")] + public string? Code { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs new file mode 100644 index 000000000..c8f79fd1c --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Recommendation dto. + /// </summary> + public class RecommendationDto + { + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the title. + /// </summary> + [JsonPropertyName("title120")] + public string? Title120 { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs new file mode 100644 index 000000000..0cd05709b --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Request schedule for channel dto. + /// </summary> + public class RequestScheduleForChannelDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// <summary> + /// Gets or sets the list of dates. + /// </summary> + [JsonPropertyName("date")] + public IReadOnlyList<string> Date { get; set; } = Array.Empty<string>(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs new file mode 100644 index 000000000..84e224b71 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Show image dto. + /// </summary> + public class ShowImagesDto + { + /// <summary> + /// Gets or sets the program id. + /// </summary> + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// <summary> + /// Gets or sets the list of data. + /// </summary> + [JsonPropertyName("data")] + public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs new file mode 100644 index 000000000..d797fd49b --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Station dto. + /// </summary> + public class StationDto + { + /// <summary> + /// Gets or sets the station id. + /// </summary> + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// <summary> + /// Gets or sets the name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the callsign. + /// </summary> + [JsonPropertyName("callsign")] + public string? Callsign { get; set; } + + /// <summary> + /// Gets or sets the broadcast language. + /// </summary> + [JsonPropertyName("broadcastLanguage")] + public IReadOnlyList<string> BroadcastLanguage { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the description language. + /// </summary> + [JsonPropertyName("descriptionLanguage")] + public IReadOnlyList<string> DescriptionLanguage { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the broadcaster. + /// </summary> + [JsonPropertyName("broadcaster")] + public BroadcasterDto? Broadcaster { get; set; } + + /// <summary> + /// Gets or sets the affiliate. + /// </summary> + [JsonPropertyName("affiliate")] + public string? Affiliate { get; set; } + + /// <summary> + /// Gets or sets the logo. + /// </summary> + [JsonPropertyName("logo")] + public LogoDto? Logo { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether it is commercial free. + /// </summary> + [JsonPropertyName("isCommercialFree")] + public bool? IsCommercialFree { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs new file mode 100644 index 000000000..61cd4a9b0 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// Title dto. + /// </summary> + public class TitleDto + { + /// <summary> + /// Gets or sets the title. + /// </summary> + [JsonPropertyName("title120")] + public string? Title120 { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs new file mode 100644 index 000000000..afb999486 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json.Serialization; + +namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos +{ + /// <summary> + /// The token dto. + /// </summary> + public class TokenDto + { + /// <summary> + /// Gets or sets the response code. + /// </summary> + [JsonPropertyName("code")] + public int Code { get; set; } + + /// <summary> + /// Gets or sets the response message. + /// </summary> + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// <summary> + /// Gets or sets the server id. + /// </summary> + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// <summary> + /// Gets or sets the token. + /// </summary> + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// <summary> + /// Gets or sets the current datetime. + /// </summary> + [JsonPropertyName("datetime")] + public DateTime? TokenTimestamp { get; set; } + + /// <summary> + /// Gets or sets the response message. + /// </summary> + [JsonPropertyName("response")] + public string? Response { get; set; } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index 69b10e6da..0c0ec48d9 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -1,15 +1,20 @@ +#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.Threading; using System.Threading.Tasks; -using Emby.XmlTv.Classes; -using Emby.XmlTv.Entities; +using Jellyfin.Extensions; +using Jellyfin.XmlTv; +using Jellyfin.XmlTv.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; @@ -22,15 +27,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings public class XmlTvListingsProvider : IListingsProvider { private readonly IServerConfigurationManager _config; - private readonly IHttpClient _httpClient; - private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<XmlTvListingsProvider> _logger; private readonly IFileSystem _fileSystem; private readonly IZipClient _zipClient; - public XmlTvListingsProvider(IServerConfigurationManager config, IHttpClient httpClient, ILogger logger, IFileSystem fileSystem, IZipClient zipClient) + public XmlTvListingsProvider( + IServerConfigurationManager config, + IHttpClientFactory httpClientFactory, + ILogger<XmlTvListingsProvider> logger, + IFileSystem fileSystem, + IZipClient zipClient) { _config = config; - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; _logger = logger; _fileSystem = fileSystem; _zipClient = zipClient; @@ -50,51 +60,41 @@ namespace Emby.Server.Implementations.LiveTv.Listings return _config.Configuration.PreferredMetadataLanguage; } - private async Task<string> GetXml(string path, CancellationToken cancellationToken) + private async Task<string> GetXml(ListingsProviderInfo info, CancellationToken cancellationToken) { - _logger.LogInformation("xmltv path: {path}", path); + _logger.LogInformation("xmltv path: {Path}", info.Path); - if (!path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - return UnzipIfNeeded(path, path); + return UnzipIfNeeded(info.Path, info.Path); } - string cacheFilename = DateTime.UtcNow.DayOfYear.ToString(CultureInfo.InvariantCulture) + "-" + DateTime.UtcNow.Hour.ToString(CultureInfo.InvariantCulture) + ".xml"; + string cacheFilename = DateTime.UtcNow.DayOfYear.ToString(CultureInfo.InvariantCulture) + "-" + DateTime.UtcNow.Hour.ToString(CultureInfo.InvariantCulture) + "-" + info.Id + ".xml"; string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); if (File.Exists(cacheFile)) { - return UnzipIfNeeded(path, cacheFile); + return UnzipIfNeeded(info.Path, cacheFile); } - _logger.LogInformation("Downloading xmltv listings from {path}", path); - - string tempFile = await _httpClient.GetTempFile(new HttpRequestOptions - { - CancellationToken = cancellationToken, - Url = path, - Progress = new SimpleProgress<double>(), - DecompressionMethod = CompressionMethod.Gzip, - - // It's going to come back gzipped regardless of this value - // So we need to make sure the decompression method is set to gzip - EnableHttpCompression = true, - - UserAgent = "Emby/3.0" - - }).ConfigureAwait(false); + _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); - File.Copy(tempFile, cacheFile, true); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.CopyToBufferSize, FileOptions.Asynchronous)) + { + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } - return UnzipIfNeeded(path, cacheFile); + return UnzipIfNeeded(info.Path, cacheFile); } - private string UnzipIfNeeded(string originalUrl, string file) + private string UnzipIfNeeded(ReadOnlySpan<char> originalUrl, string file) { - string ext = Path.GetExtension(originalUrl.Split('?')[0]); + ReadOnlySpan<char> ext = Path.GetExtension(originalUrl.LeftPart('?')); - if (string.Equals(ext, ".gz", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".gz", StringComparison.OrdinalIgnoreCase)) { try { @@ -103,7 +103,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings } catch (Exception ex) { - _logger.LogError(ex, "Error extracting from gz file {file}", file); + _logger.LogError(ex, "Error extracting from gz file {File}", file); } try @@ -113,7 +113,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings } catch (Exception ex) { - _logger.LogError(ex, "Error extracting from zip file {file}", file); + _logger.LogError(ex, "Error extracting from zip file {File}", file); } } @@ -161,20 +161,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings throw new ArgumentNullException(nameof(channelId)); } - /* - if (!await EmbyTV.EmbyTVRegistration.Instance.EnableXmlTv().ConfigureAwait(false)) - { - var length = endDateUtc - startDateUtc; - if (length.TotalDays > 1) - { - endDateUtc = startDateUtc.AddDays(1); - } - }*/ + _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId); - _logger.LogDebug("Getting xmltv programs for channel {id}", channelId); - - string path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {path}", path); + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); var reader = new XmlTvReader(path, GetLanguage(info)); return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) @@ -208,7 +198,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings HasImage = program.Icon != null && !string.IsNullOrEmpty(program.Icon.Source), OfficialRating = program.Rating != null && !string.IsNullOrEmpty(program.Rating.Value) ? program.Rating.Value : null, CommunityRating = program.StarRating, - SeriesId = program.Episode == null ? null : program.Title.GetMD5().ToString("N") + SeriesId = program.Episode == null ? null : program.Title.GetMD5().ToString("N", CultureInfo.InvariantCulture) }; if (string.IsNullOrWhiteSpace(program.ProgramId)) @@ -219,19 +209,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings { uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture); } + if (programInfo.EpisodeNumber.HasValue) { uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture); } - programInfo.ShowId = uniqueString.GetMD5().ToString("N"); + programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture); // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skipped if (programInfo.IsSeries && !programInfo.IsRepeat && (programInfo.EpisodeNumber ?? 0) == 0) { - programInfo.ShowId = programInfo.ShowId + programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture); + programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture); } } else @@ -240,7 +231,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings } // Construct an id from the channel and start date - programInfo.Id = string.Format("{0}_{1:O}", program.ChannelId, program.StartDate); + programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDate); if (programInfo.IsMovie) { @@ -266,8 +257,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location) { // In theory this should never be called because there is always only one lineup - string path = await GetXml(info.Path, CancellationToken.None).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {path}", path); + string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); var reader = new XmlTvReader(path, GetLanguage(info)); IEnumerable<XmlTvChannel> results = reader.GetChannels(); @@ -278,8 +269,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) { // In theory this should never be called because there is always only one lineup - string path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {path}", path); + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); var reader = new XmlTvReader(path, GetLanguage(info)); var results = reader.GetChannels(); @@ -290,7 +281,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings Name = c.DisplayName, ImageUrl = c.Icon != null && !string.IsNullOrEmpty(c.Icon.Source) ? c.Icon.Source : null, Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number - }).ToList(); } } diff --git a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs index f9b274acb..098f193fb 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs @@ -4,16 +4,20 @@ 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 1144c9ab1..21e1409ac 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs @@ -1,4 +1,9 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -19,9 +24,12 @@ namespace Emby.Server.Implementations.LiveTv { public class LiveTvDtoService { - private readonly ILogger _logger; - private readonly IImageProcessor _imageProcessor; + private const string InternalVersionNumber = "4"; + + private const string ServiceName = "Emby"; + private readonly ILogger<LiveTvDtoService> _logger; + private readonly IImageProcessor _imageProcessor; private readonly IDtoService _dtoService; private readonly IApplicationHost _appHost; private readonly ILibraryManager _libraryManager; @@ -29,13 +37,13 @@ namespace Emby.Server.Implementations.LiveTv public LiveTvDtoService( IDtoService dtoService, IImageProcessor imageProcessor, - ILoggerFactory loggerFactory, + ILogger<LiveTvDtoService> logger, IApplicationHost appHost, ILibraryManager libraryManager) { _dtoService = dtoService; _imageProcessor = imageProcessor; - _logger = loggerFactory.CreateLogger(nameof(LiveTvDtoService)); + _logger = logger; _appHost = appHost; _libraryManager = libraryManager; } @@ -52,7 +60,7 @@ namespace Emby.Server.Implementations.LiveTv ExternalId = info.Id, ChannelId = GetInternalChannelId(service.Name, info.ChannelId), Status = info.Status, - SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N"), + SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture), PrePaddingSeconds = info.PrePaddingSeconds, PostPaddingSeconds = info.PostPaddingSeconds, IsPostPaddingRequired = info.IsPostPaddingRequired, @@ -69,7 +77,7 @@ namespace Emby.Server.Implementations.LiveTv if (!string.IsNullOrEmpty(info.ProgramId)) { - dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N"); + dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); } if (program != null) @@ -107,7 +115,7 @@ namespace Emby.Server.Implementations.LiveTv { var dto = new SeriesTimerInfoDto { - Id = GetInternalSeriesTimerId(info.Id).ToString("N"), + Id = GetInternalSeriesTimerId(info.Id).ToString("N", CultureInfo.InvariantCulture), Overview = info.Overview, EndDate = info.EndDate, Name = info.Name, @@ -139,7 +147,7 @@ namespace Emby.Server.Implementations.LiveTv if (!string.IsNullOrEmpty(info.ProgramId)) { - dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N"); + dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); } dto.DayPattern = info.Days == null ? null : GetDayPattern(info.Days.ToArray()); @@ -153,12 +161,11 @@ 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 }, DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); if (librarySeries != null) @@ -169,13 +176,14 @@ namespace Emby.Server.Implementations.LiveTv try { dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); - dto.ParentThumbItemId = librarySeries.Id.ToString("N"); + dto.ParentThumbItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { _logger.LogError(ex, "Error"); } } + image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); if (image != null) { @@ -185,7 +193,7 @@ namespace Emby.Server.Implementations.LiveTv { _imageProcessor.GetImageCacheTag(librarySeries, image) }; - dto.ParentBackdropItemId = librarySeries.Id.ToString("N"); + dto.ParentBackdropItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { @@ -196,13 +204,12 @@ namespace Emby.Server.Implementations.LiveTv var 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 }, DtoOptions = new DtoOptions(false), Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null - }).FirstOrDefault(); if (program != null) @@ -213,7 +220,7 @@ namespace Emby.Server.Implementations.LiveTv try { dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N"); + dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { @@ -229,10 +236,11 @@ namespace Emby.Server.Implementations.LiveTv try { dto.ParentBackdropImageTags = new string[] - { + { _imageProcessor.GetImageCacheTag(program, image) - }; - dto.ParentBackdropItemId = program.Id.ToString("N"); + }; + + dto.ParentBackdropItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { @@ -247,12 +255,11 @@ 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 }, DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); if (librarySeries != null) @@ -263,13 +270,14 @@ namespace Emby.Server.Implementations.LiveTv try { dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); - dto.ParentThumbItemId = librarySeries.Id.ToString("N"); + dto.ParentThumbItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { _logger.LogError(ex, "Error"); } } + image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); if (image != null) { @@ -279,7 +287,7 @@ namespace Emby.Server.Implementations.LiveTv { _imageProcessor.GetImageCacheTag(librarySeries, image) }; - dto.ParentBackdropItemId = librarySeries.Id.ToString("N"); + dto.ParentBackdropItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { @@ -290,25 +298,23 @@ 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 }, DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); if (program == null) { 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 }, DtoOptions = new DtoOptions(false), Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null - }).FirstOrDefault(); } @@ -320,7 +326,7 @@ namespace Emby.Server.Implementations.LiveTv try { dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N"); + dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { @@ -339,7 +345,7 @@ namespace Emby.Server.Implementations.LiveTv { _imageProcessor.GetImageCacheTag(program, image) }; - dto.ParentBackdropItemId = program.Id.ToString("N"); + dto.ParentBackdropItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); } catch (Exception ex) { @@ -393,8 +399,6 @@ namespace Emby.Server.Implementations.LiveTv return null; } - private const string InternalVersionNumber = "4"; - public Guid GetInternalChannelId(string serviceName, string externalId) { var name = serviceName + externalId + InternalVersionNumber; @@ -402,12 +406,11 @@ namespace Emby.Server.Implementations.LiveTv return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvChannel)); } - private const string ServiceName = "Emby"; public string GetInternalTimerId(string externalId) { var name = ServiceName + externalId + InternalVersionNumber; - return name.ToLowerInvariant().GetMD5().ToString("N"); + return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); } public Guid GetInternalSeriesTimerId(string externalId) diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index f7ef16fb0..ea1a28fe8 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1,20 +1,25 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Library; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; -using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -22,96 +27,102 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Events; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; +using Episode = MediaBrowser.Controller.Entities.TV.Episode; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; namespace Emby.Server.Implementations.LiveTv { /// <summary> - /// Class LiveTvManager + /// Class LiveTvManager. /// </summary> public class LiveTvManager : ILiveTvManager, IDisposable { + private const int MaxGuideDays = 14; + private const string ExternalServiceTag = "ExternalServiceId"; + + private const string EtagKey = "ProgramEtag"; + private readonly IServerConfigurationManager _config; - private readonly ILogger _logger; + private readonly ILogger<LiveTvManager> _logger; private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; private readonly ITaskManager _taskManager; - private readonly IJsonSerializer _jsonSerializer; - private readonly Func<IChannelManager> _channelManager; - - private readonly IDtoService _dtoService; private readonly ILocalizationManager _localization; - + private readonly IFileSystem _fileSystem; + private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; private ILiveTvService[] _services = Array.Empty<ILiveTvService>(); - private ITunerHost[] _tunerHosts = Array.Empty<ITunerHost>(); private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>(); - private readonly IFileSystem _fileSystem; - public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; - public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled; - public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated; - public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated; - - public string GetEmbyTvActiveRecordingPath(string id) - { - return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); - } + private bool _disposed = false; public LiveTvManager( - IServerApplicationHost appHost, IServerConfigurationManager config, - ILoggerFactory loggerFactory, + ILogger<LiveTvManager> logger, IItemRepository itemRepo, - IImageProcessor imageProcessor, IUserDataManager userDataManager, IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, ITaskManager taskManager, ILocalizationManager localization, - IJsonSerializer jsonSerializer, IFileSystem fileSystem, - Func<IChannelManager> channelManager) + IChannelManager channelManager, + LiveTvDtoService liveTvDtoService) { _config = config; - _logger = loggerFactory.CreateLogger(nameof(LiveTvManager)); + _logger = logger; _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; _taskManager = taskManager; _localization = localization; - _jsonSerializer = jsonSerializer; _fileSystem = fileSystem; _dtoService = dtoService; _userDataManager = userDataManager; _channelManager = channelManager; - - _tvDtoService = new LiveTvDtoService(dtoService, imageProcessor, loggerFactory, appHost, _libraryManager); + _tvDtoService = liveTvDtoService; } + public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; + + public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled; + + public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated; + + public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated; + /// <summary> /// Gets the services. /// </summary> /// <value>The services.</value> public IReadOnlyList<ILiveTvService> Services => _services; + public ITunerHost[] TunerHosts => _tunerHosts; + + public IListingsProvider[] ListingProviders => _listingProviders; + private LiveTvOptions GetConfiguration() { return _config.GetConfiguration<LiveTvOptions>("livetv"); } + public string GetEmbyTvActiveRecordingPath(string id) + { + return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); + } + /// <summary> /// Adds the parts. /// </summary> @@ -129,51 +140,36 @@ namespace Emby.Server.Implementations.LiveTv { if (service is EmbyTV.EmbyTV embyTv) { - embyTv.TimerCreated += EmbyTv_TimerCreated; - embyTv.TimerCancelled += EmbyTv_TimerCancelled; + embyTv.TimerCreated += OnEmbyTvTimerCreated; + embyTv.TimerCancelled += OnEmbyTvTimerCancelled; } } } - private void EmbyTv_TimerCancelled(object sender, GenericEventArgs<string> e) + private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs<string> e) { var timerId = e.Argument; - TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo> - { - Argument = new TimerEventInfo - { - Id = timerId - } - }); + TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(timerId))); } - private void EmbyTv_TimerCreated(object sender, GenericEventArgs<TimerInfo> e) + private void OnEmbyTvTimerCreated(object sender, GenericEventArgs<TimerInfo> e) { var timer = e.Argument; - var service = sender as ILiveTvService; - TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo> - { - Argument = new TimerEventInfo + TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>( + new TimerEventInfo(timer.Id) { - ProgramId = _tvDtoService.GetInternalProgramId(timer.ProgramId), - Id = timer.Id - } - }); + ProgramId = _tvDtoService.GetInternalProgramId(timer.ProgramId) + })); } - public ITunerHost[] TunerHosts => _tunerHosts; - - public IListingsProvider[] ListingProviders => _listingProviders; - public List<NameIdPair> GetTunerHostTypes() { return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair { Name = i.Name, Id = i.Type - }).ToList(); } @@ -195,7 +191,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, @@ -206,16 +202,16 @@ namespace Emby.Server.Implementations.LiveTv var orderBy = internalQuery.OrderBy.ToList(); - orderBy.AddRange(query.SortBy.Select(i => new ValueTuple<string, SortOrder>(i, query.SortOrder ?? SortOrder.Ascending))); + orderBy.AddRange(query.SortBy.Select(i => (i, query.SortOrder ?? SortOrder.Ascending))); if (query.EnableFavoriteSorting) { - orderBy.Insert(0, new ValueTuple<string, SortOrder>(ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); + orderBy.Insert(0, (ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); } if (!internalQuery.OrderBy.Any(i => string.Equals(i.Item1, ItemSortBy.SortName, StringComparison.OrdinalIgnoreCase))) { - orderBy.Add(new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending)); + orderBy.Add((ItemSortBy.SortName, SortOrder.Ascending)); } internalQuery.OrderBy = orderBy.ToArray(); @@ -256,9 +252,10 @@ namespace Emby.Server.Implementations.LiveTv var endTime = DateTime.UtcNow; _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); } + info.RequiresClosing = true; - var idPrefix = service.GetType().FullName.GetMD5().ToString("N") + "_"; + var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; info.LiveStreamId = idPrefix + info.Id; @@ -301,9 +298,12 @@ namespace Emby.Server.Implementations.LiveTv } private ILiveTvService GetService(string name) - { - return _services.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); - } + => Array.Find(_services, x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? throw new KeyNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "No service with the name '{0}' can be found.", + name)); private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) { @@ -354,30 +354,37 @@ namespace Emby.Server.Implementations.LiveTv { stream.BitRate = null; } + if (stream.Channels.HasValue && stream.Channels <= 0) { stream.Channels = null; } + if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) { stream.AverageFrameRate = null; } + if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) { stream.RealFrameRate = null; } + if (stream.Width.HasValue && stream.Width <= 0) { stream.Width = null; } + if (stream.Height.HasValue && stream.Height <= 0) { stream.Height = null; } + if (stream.SampleRate.HasValue && stream.SampleRate <= 0) { stream.SampleRate = null; } + if (stream.Level.HasValue && stream.Level <= 0) { stream.Level = null; @@ -398,11 +405,11 @@ namespace Emby.Server.Implementations.LiveTv // Set the total bitrate if not already supplied mediaSource.InferTotalBitrate(); - if (!(service is EmbyTV.EmbyTV)) + if (service is not EmbyTV.EmbyTV) { // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says - //mediaSource.SupportsDirectPlay = false; - //mediaSource.SupportsDirectStream = false; + // mediaSource.SupportsDirectPlay = false; + // mediaSource.SupportsDirectStream = false; mediaSource.SupportsTranscoding = true; foreach (var stream in mediaSource.MediaStreams) { @@ -419,8 +426,7 @@ namespace Emby.Server.Implementations.LiveTv } } - private const string ExternalServiceTag = "ExternalServiceId"; - private LiveTvChannel GetChannel(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) + private async Task<LiveTvChannel> GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) { var parentFolderId = parentFolder.Id; var isNew = false; @@ -448,6 +454,7 @@ namespace Emby.Server.Implementations.LiveTv { isNew = true; } + item.Tags = channelInfo.Tags; } @@ -455,6 +462,7 @@ namespace Emby.Server.Implementations.LiveTv { isNew = true; } + item.ParentId = parentFolderId; item.ChannelType = channelInfo.ChannelType; @@ -464,24 +472,28 @@ namespace Emby.Server.Implementations.LiveTv { forceUpdate = true; } + item.SetProviderId(ExternalServiceTag, serviceName); if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) { forceUpdate = true; } + item.ExternalId = channelInfo.Id; if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) { forceUpdate = true; } + item.Number = channelInfo.Number; if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) { forceUpdate = true; } + item.Name = channelInfo.Name; if (!item.HasImage(ImageType.Primary)) @@ -504,15 +516,13 @@ namespace Emby.Server.Implementations.LiveTv } else if (forceUpdate) { - _libraryManager.UpdateItem(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken); + await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); } return item; } - private const string EtagKey = "ProgramEtag"; - - private Tuple<LiveTvProgram, bool, bool> GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel, ChannelType channelType, string serviceName, CancellationToken cancellationToken) + private (LiveTvProgram item, bool isNew, bool isUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel) { var id = _tvDtoService.GetInternalProgramId(info.Id); @@ -548,13 +558,12 @@ namespace Emby.Server.Implementations.LiveTv { forceUpdate = true; } - item.ParentId = channel.Id; - //item.ChannelType = channelType; + item.ParentId = channel.Id; item.Audio = info.Audio; item.ChannelId = channel.Id; - item.CommunityRating = item.CommunityRating ?? info.CommunityRating; + item.CommunityRating ??= info.CommunityRating; if ((item.CommunityRating ?? 0).Equals(0)) { item.CommunityRating = null; @@ -567,6 +576,7 @@ namespace Emby.Server.Implementations.LiveTv { forceUpdate = true; } + item.ExternalSeriesId = seriesId; var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); @@ -581,30 +591,37 @@ namespace Emby.Server.Implementations.LiveTv { tags.Add("Live"); } + if (info.IsPremiere) { tags.Add("Premiere"); } + if (info.IsNews) { tags.Add("News"); } + if (info.IsSports) { tags.Add("Sports"); } + if (info.IsKids) { tags.Add("Kids"); } + if (info.IsRepeat) { tags.Add("Repeat"); } + if (info.IsMovie) { tags.Add("Movie"); } + if (isSeries) { tags.Add("Series"); @@ -627,11 +644,12 @@ namespace Emby.Server.Implementations.LiveTv { forceUpdate = true; } + item.IsSeries = isSeries; item.Name = info.Name; - item.OfficialRating = item.OfficialRating ?? info.OfficialRating; - item.Overview = item.Overview ?? info.Overview; + item.OfficialRating ??= info.OfficialRating; + item.Overview ??= info.Overview; item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; item.ProviderIds = info.ProviderIds; @@ -644,12 +662,14 @@ namespace Emby.Server.Implementations.LiveTv { forceUpdate = true; } + item.StartDate = info.StartDate; if (item.EndDate != info.EndDate) { forceUpdate = true; } + item.EndDate = info.EndDate; item.ProductionYear = info.ProductionYear; @@ -666,19 +686,23 @@ namespace Emby.Server.Implementations.LiveTv { if (!string.IsNullOrWhiteSpace(info.ImagePath)) { - item.SetImage(new ItemImageInfo - { - Path = info.ImagePath, - Type = ImageType.Primary - }, 0); + item.SetImage( + new ItemImageInfo + { + Path = info.ImagePath, + Type = ImageType.Primary + }, + 0); } else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) { - item.SetImage(new ItemImageInfo - { - Path = info.ImageUrl, - Type = ImageType.Primary - }, 0); + item.SetImage( + new ItemImageInfo + { + Path = info.ImageUrl, + Type = ImageType.Primary + }, + 0); } } @@ -686,12 +710,13 @@ namespace Emby.Server.Implementations.LiveTv { if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) { - item.SetImage(new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - - }, 0); + item.SetImage( + new ItemImageInfo + { + Path = info.ThumbImageUrl, + Type = ImageType.Thumb + }, + 0); } } @@ -699,12 +724,13 @@ namespace Emby.Server.Implementations.LiveTv { if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) { - item.SetImage(new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - - }, 0); + item.SetImage( + new ItemImageInfo + { + Path = info.LogoImageUrl, + Type = ImageType.Logo + }, + 0); } } @@ -712,12 +738,13 @@ namespace Emby.Server.Implementations.LiveTv { if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) { - item.SetImage(new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - - }, 0); + item.SetImage( + new ItemImageInfo + { + Path = info.BackdropImageUrl, + Type = ImageType.Backdrop + }, + 0); } } @@ -745,7 +772,7 @@ namespace Emby.Server.Implementations.LiveTv item.OnMetadataChanged(); } - return new Tuple<LiveTvProgram, bool, bool>(item, isNew, isUpdated); + return (item, isNew, isUpdated); } public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null) @@ -754,7 +781,8 @@ namespace Emby.Server.Implementations.LiveTv var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); - var list = new List<Tuple<BaseItemDto, string, string>>() { + var list = new List<Tuple<BaseItemDto, string, string>> + { new Tuple<BaseItemDto, string, string>(dto, program.ExternalId, program.ExternalSeriesId) }; @@ -769,31 +797,20 @@ namespace Emby.Server.Implementations.LiveTv var topFolder = GetInternalLiveTvFolder(cancellationToken); - if (query.OrderBy.Length == 0) + if (query.OrderBy.Count == 0) { - if (query.IsAiring ?? false) - { - // Unless something else was specified, order by start date to take advantage of a specialized index - query.OrderBy = new ValueTuple<string, SortOrder>[] - { - new ValueTuple<string, SortOrder>(ItemSortBy.StartDate, SortOrder.Ascending) - }; - } - else + // Unless something else was specified, order by start date to take advantage of a specialized index + query.OrderBy = new[] { - // Unless something else was specified, order by start date to take advantage of a specialized index - query.OrderBy = new ValueTuple<string, SortOrder>[] - { - new ValueTuple<string, SortOrder>(ItemSortBy.StartDate, SortOrder.Ascending) - }; - } + (ItemSortBy.StartDate, SortOrder.Ascending) + }; } RemoveFields(options); var internalQuery = new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, MinEndDate = query.MinEndDate, MinStartDate = query.MinStartDate, MaxEndDate = query.MaxEndDate, @@ -819,8 +836,8 @@ namespace Emby.Server.Implementations.LiveTv if (!string.IsNullOrWhiteSpace(query.SeriesTimerId)) { - var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery { }, cancellationToken).ConfigureAwait(false); - var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.Id).ToString("N"), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase)); + var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false); + var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.Id).ToString("N", CultureInfo.InvariantCulture), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase)); if (seriesTimer != null) { internalQuery.ExternalSeriesId = seriesTimer.SeriesId; @@ -842,13 +859,11 @@ namespace Emby.Server.Implementations.LiveTv var returnArray = _dtoService.GetBaseItemDtos(queryResult.Items, options, user); - var result = new QueryResult<BaseItemDto> + return new QueryResult<BaseItemDto> { Items = returnArray, TotalRecordCount = queryResult.TotalRecordCount }; - - return result; } public QueryResult<BaseItem> GetRecommendedProgramsInternal(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) @@ -859,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, @@ -868,7 +883,7 @@ namespace Emby.Server.Implementations.LiveTv IsSports = query.IsSports, IsKids = query.IsKids, EnableTotalRecordCount = query.EnableTotalRecordCount, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.StartDate, SortOrder.Ascending) }, + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }, TopParentIds = new[] { topFolder.Id }, DtoOptions = options, GenreIds = query.GenreIds @@ -880,7 +895,7 @@ namespace Emby.Server.Implementations.LiveTv } var programList = _libraryManager.QueryItems(internalQuery).Items; - var totalCount = programList.Length; + var totalCount = programList.Count; var orderedPrograms = programList.Cast<LiveTvProgram>().OrderBy(i => i.StartDate.Date); @@ -965,11 +980,8 @@ namespace Emby.Server.Implementations.LiveTv private async Task AddRecordingInfo(IEnumerable<Tuple<BaseItemDto, string, string>> programs, CancellationToken cancellationToken) { - var timers = new Dictionary<string, List<TimerInfo>>(); - var seriesTimers = new Dictionary<string, List<SeriesTimerInfo>>(); - - TimerInfo[] timerList = null; - SeriesTimerInfo[] seriesTimerList = null; + IReadOnlyList<TimerInfo> timerList = null; + IReadOnlyList<SeriesTimerInfo> seriesTimerList = null; foreach (var programTuple in programs) { @@ -977,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; @@ -997,7 +1006,7 @@ namespace Emby.Server.Implementations.LiveTv if (!string.IsNullOrEmpty(timer.SeriesTimerId)) { program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(timer.SeriesTimerId) - .ToString("N"); + .ToString("N", CultureInfo.InvariantCulture); foundSeriesTimer = true; } @@ -1008,17 +1017,14 @@ 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)); if (seriesTimer != null) { program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(seriesTimer.Id) - .ToString("N"); + .ToString("N", CultureInfo.InvariantCulture); } } } @@ -1079,16 +1085,16 @@ 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(); if (coreService != null) { - await coreService.RefreshSeriesTimers(cancellationToken, new SimpleProgress<double>()).ConfigureAwait(false); - await coreService.RefreshTimers(cancellationToken, new SimpleProgress<double>()).ConfigureAwait(false); + await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); + await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); } // Load these now which will prefetch metadata @@ -1119,7 +1125,7 @@ namespace Emby.Server.Implementations.LiveTv try { - var item = GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken); + var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); list.Add(item); } @@ -1136,7 +1142,7 @@ namespace Emby.Server.Implementations.LiveTv double percent = numComplete; percent /= allChannelsList.Count; - progress.Report(5 * percent + 10); + progress.Report((5 * percent) + 10); } progress.Report(15); @@ -1171,11 +1177,9 @@ 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); var newPrograms = new List<LiveTvProgram>(); @@ -1183,14 +1187,14 @@ namespace Emby.Server.Implementations.LiveTv foreach (var program in channelPrograms) { - var programTuple = GetProgram(program, existingPrograms, currentChannel, currentChannel.ChannelType, service.Name, cancellationToken); - var programItem = programTuple.Item1; + var programTuple = GetProgram(program, existingPrograms, currentChannel); + var programItem = programTuple.item; - if (programTuple.Item2) + if (programTuple.isNew) { newPrograms.Add(programItem); } - else if (programTuple.Item3) + else if (programTuple.isUpdated) { updatedPrograms.Add(programItem); } @@ -1213,7 +1217,11 @@ namespace Emby.Server.Implementations.LiveTv if (updatedPrograms.Count > 0) { - _libraryManager.UpdateItems(updatedPrograms, currentChannel, ItemUpdateType.MetadataImport, cancellationToken); + await _libraryManager.UpdateItemsAsync( + updatedPrograms, + currentChannel, + ItemUpdateType.MetadataImport, + cancellationToken).ConfigureAwait(false); } currentChannel.IsMovie = isMovie; @@ -1226,12 +1234,13 @@ namespace Emby.Server.Implementations.LiveTv currentChannel.AddTag("Kids"); } - //currentChannel.UpdateToRepository(ItemUpdateType.MetadataImport, cancellationToken); - await currentChannel.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - ForceSave = true - - }, cancellationToken).ConfigureAwait(false); + await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await currentChannel.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -1245,7 +1254,7 @@ namespace Emby.Server.Implementations.LiveTv numComplete++; double percent = numComplete / (double)allChannelsList.Count; - progress.Report(85 * percent + 15); + progress.Report((85 * percent) + 15); } progress.Report(100); @@ -1278,12 +1287,14 @@ namespace Emby.Server.Implementations.LiveTv if (item != null) { - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false, - DeleteFromExternalProvider = false - - }, false); + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + false); } } @@ -1294,7 +1305,6 @@ namespace Emby.Server.Implementations.LiveTv } } - private const int MaxGuideDays = 14; private double GetGuideDays() { var config = GetConfiguration(); @@ -1332,28 +1342,31 @@ 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)); } } + if (query.IsSeries.HasValue) { if (query.IsSeries.Value) { - includeItemTypes.Add(typeof(Episode).Name); + includeItemTypes.Add(nameof(Episode)); } else { - excludeItemTypes.Add(typeof(Episode).Name); + excludeItemTypes.Add(nameof(Episode)); } } + if (query.IsSports ?? false) { genres.Add("Sports"); } + if (query.IsKids ?? false) { genres.Add("Kids"); @@ -1368,14 +1381,14 @@ namespace Emby.Server.Implementations.LiveTv // limit = (query.Limit ?? 10) * 2; limit = null; - //var allActivePaths = EmbyTV.EmbyTV.Current.GetAllActiveRecordings().Select(i => i.Path).ToArray(); - //var items = allActivePaths.Select(i => _libraryManager.FindByPath(i, false)).Where(i => i != null).ToArray(); + // var allActivePaths = EmbyTV.EmbyTV.Current.GetAllActiveRecordings().Select(i => i.Path).ToArray(); + // var items = allActivePaths.Select(i => _libraryManager.FindByPath(i, false)).Where(i => i != null).ToArray(); - //return new QueryResult<BaseItem> - //{ + // return new QueryResult<BaseItem> + // { // Items = items, // TotalRecordCount = items.Length - //}; + // }; dtoOptions.Fields = dtoOptions.Fields.Concat(new[] { ItemFields.Tags }).Distinct().ToArray(); } @@ -1389,7 +1402,7 @@ namespace Emby.Server.Implementations.LiveTv IsVirtualItem = false, Limit = limit, StartIndex = query.StartIndex, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DateCreated, SortOrder.Descending) }, + OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, EnableTotalRecordCount = query.EnableTotalRecordCount, IncludeItemTypes = includeItemTypes.ToArray(), ExcludeItemTypes = excludeItemTypes.ToArray(), @@ -1399,29 +1412,28 @@ namespace Emby.Server.Implementations.LiveTv if (query.IsInProgress ?? false) { - //TODO Fix The co-variant conversion between Video[] and BaseItem[], this can generate runtime issues. + // TODO: Fix The co-variant conversion between Video[] and BaseItem[], this can generate runtime issues. result.Items = result .Items .OfType<Video>() .Where(i => !i.IsCompleteMedia) .ToArray(); - result.TotalRecordCount = result.Items.Length; + result.TotalRecordCount = result.Items.Count; } return result; } - public Task AddInfoToProgramDto(List<Tuple<BaseItem, BaseItemDto>> tuples, ItemFields[] fields, User user = null) + public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null) { var programTuples = new List<Tuple<BaseItemDto, string, string>>(); var hasChannelImage = fields.Contains(ItemFields.ChannelImage); var hasChannelInfo = fields.Contains(ItemFields.ChannelInfo); - foreach (var tuple in tuples) + foreach (var (item, dto) in programs) { - var program = (LiveTvProgram)tuple.Item1; - var dto = tuple.Item2; + var program = (LiveTvProgram)item; dto.StartDate = program.StartDate; dto.EpisodeTitle = program.EpisodeTitle; @@ -1472,7 +1484,7 @@ namespace Emby.Server.Implementations.LiveTv dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null - : _tvDtoService.GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N"); + : _tvDtoService.GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture); dto.TimerId = string.IsNullOrEmpty(info.Id) ? null @@ -1596,8 +1608,6 @@ namespace Emby.Server.Implementations.LiveTv if (!string.IsNullOrEmpty(query.Id)) { - var guid = new Guid(query.Id); - timers = timers .Where(i => string.Equals(_tvDtoService.GetInternalTimerId(i.Item1.Id), query.Id, StringComparison.OrdinalIgnoreCase)); } @@ -1706,22 +1716,16 @@ namespace Emby.Server.Implementations.LiveTv if (timer == null) { - throw new ResourceNotFoundException(string.Format("Timer with Id {0} not found", id)); + throw new ResourceNotFoundException(string.Format(CultureInfo.InvariantCulture, "Timer with Id {0} not found", id)); } var service = GetService(timer.ServiceName); await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false); - if (!(service is EmbyTV.EmbyTV)) + if (service is not EmbyTV.EmbyTV) { - TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo> - { - Argument = new TimerEventInfo - { - Id = id - } - }); + TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id))); } } @@ -1731,29 +1735,24 @@ namespace Emby.Server.Implementations.LiveTv if (timer == null) { - throw new ResourceNotFoundException(string.Format("SeriesTimer with Id {0} not found", id)); + throw new ResourceNotFoundException(string.Format(CultureInfo.InvariantCulture, "SeriesTimer with Id {0} not found", id)); } var service = GetService(timer.ServiceName); await service.CancelSeriesTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false); - SeriesTimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo> - { - Argument = new TimerEventInfo - { - Id = id - } - }); + SeriesTimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id))); } public async Task<TimerInfoDto> GetTimer(string id, CancellationToken cancellationToken) { - var results = await GetTimers(new TimerQuery - { - Id = id - - }, cancellationToken).ConfigureAwait(false); + var results = await GetTimers( + new TimerQuery + { + Id = id + }, + cancellationToken).ConfigureAwait(false); return results.Items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); } @@ -1801,11 +1800,7 @@ namespace Emby.Server.Implementations.LiveTv } var returnArray = timers - .Select(i => - { - return i.Item1; - - }) + .Select(i => i.Item1) .ToArray(); return new QueryResult<SeriesTimerInfo> @@ -1859,7 +1854,6 @@ namespace Emby.Server.Implementations.LiveTv } return _tvDtoService.GetSeriesTimerInfoDto(i.Item1, i.Item2, channelName); - }) .ToArray(); @@ -1876,23 +1870,22 @@ namespace Emby.Server.Implementations.LiveTv return _libraryManager.GetItemById(internalChannelId); } - public void AddChannelInfo(List<Tuple<BaseItemDto, LiveTvChannel>> tuples, DtoOptions options, User user) + public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto, LiveTvChannel)> items, DtoOptions options, User user) { var now = DateTime.UtcNow; - var channelIds = tuples.Select(i => i.Item2.Id).Distinct().ToArray(); + var channelIds = items.Select(i => i.Item2.Id).Distinct().ToArray(); var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IncludeItemTypes = new[] { nameof(LiveTvProgram) }, ChannelIds = channelIds, MaxStartDate = now, MinEndDate = now, Limit = channelIds.Length, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.StartDate, SortOrder.Ascending) }, + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }, TopParentIds = new[] { GetInternalLiveTvFolder(CancellationToken.None).Id }, DtoOptions = options - }) : new List<BaseItem>(); RemoveFields(options); @@ -1902,7 +1895,7 @@ namespace Emby.Server.Implementations.LiveTv var addCurrentProgram = options.AddCurrentProgram; - foreach (var tuple in tuples) + foreach (var tuple in items) { var dto = tuple.Item1; var channel = tuple.Item2; @@ -1930,7 +1923,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; } @@ -1970,16 +1963,13 @@ namespace Emby.Server.Implementations.LiveTv OriginalAirDate = program.PremiereDate, Overview = program.Overview, StartDate = program.StartDate, - //ImagePath = program.ExternalImagePath, + // ImagePath = program.ExternalImagePath, Name = program.Name, OfficialRating = program.OfficialRating }; } - if (service == null) - { - service = _services.First(); - } + service ??= _services[0]; var info = await service.GetNewTimerDefaultsAsync(cancellationToken, programInfo).ConfigureAwait(false); @@ -2004,9 +1994,7 @@ namespace Emby.Server.Implementations.LiveTv { var info = await GetNewTimerDefaultsInternal(cancellationToken).ConfigureAwait(false); - var obj = _tvDtoService.GetSeriesTimerInfoDto(info.Item1, info.Item2, null); - - return obj; + return _tvDtoService.GetSeriesTimerInfoDto(info.Item1, info.Item2, null); } public async Task<SeriesTimerInfoDto> GetNewTimerDefaults(string programId, CancellationToken cancellationToken) @@ -2022,12 +2010,12 @@ 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; info.Overview = program.Overview; - info.ProgramId = programDto.Id.ToString("N"); + info.ProgramId = programDto.Id.ToString("N", CultureInfo.InvariantCulture); info.ExternalProgramId = program.ExternalId; if (program.EndDate.HasValue) @@ -2061,16 +2049,13 @@ namespace Emby.Server.Implementations.LiveTv _logger.LogInformation("New recording scheduled"); - if (!(service is EmbyTV.EmbyTV)) + if (service is not EmbyTV.EmbyTV) { - TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo> - { - Argument = new TimerEventInfo + TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>( + new TimerEventInfo(newTimerId) { - ProgramId = _tvDtoService.GetInternalProgramId(info.ProgramId), - Id = newTimerId - } - }); + ProgramId = _tvDtoService.GetInternalProgramId(info.ProgramId) + })); } } @@ -2088,21 +2073,18 @@ namespace Emby.Server.Implementations.LiveTv if (service is ISupportsNewTimerIds supportsNewTimerIds) { newTimerId = await supportsNewTimerIds.CreateSeriesTimer(info, cancellationToken).ConfigureAwait(false); - newTimerId = _tvDtoService.GetInternalSeriesTimerId(newTimerId).ToString("N"); + newTimerId = _tvDtoService.GetInternalSeriesTimerId(newTimerId).ToString("N", CultureInfo.InvariantCulture); } else { await service.CreateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false); } - SeriesTimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo> - { - Argument = new TimerEventInfo + SeriesTimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>( + new TimerEventInfo(newTimerId) { - ProgramId = _tvDtoService.GetInternalProgramId(info.ProgramId), - Id = newTimerId - } - }); + ProgramId = _tvDtoService.GetInternalProgramId(info.ProgramId) + })); } public async Task UpdateTimer(TimerInfoDto timer, CancellationToken cancellationToken) @@ -2135,15 +2117,13 @@ namespace Emby.Server.Implementations.LiveTv }; } - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> + /// <inheritdoc /> public void Dispose() { Dispose(true); + GC.SuppressFinalize(this); } - private bool _disposed = false; /// <summary> /// Releases unmanaged and - optionally - managed resources. /// </summary> @@ -2187,20 +2167,19 @@ namespace Emby.Server.Implementations.LiveTv var info = new LiveTvInfo { Services = services, - IsEnabled = services.Length > 0 + IsEnabled = services.Length > 0, + EnabledUsers = _userManager.Users + .Where(IsLiveTvEnabled) + .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) + .ToArray() }; - info.EnabledUsers = _userManager.Users - .Where(IsLiveTvEnabled) - .Select(i => i.Id.ToString("N")) - .ToArray(); - return info; } private bool IsLiveTvEnabled(User user) { - return user.Policy.EnableLiveTvAccess && (Services.Count > 1 || GetConfiguration().TunerHosts.Length > 0); + return user.HasPermission(PermissionKind.EnableLiveTvAccess) && (Services.Count > 1 || GetConfiguration().TunerHosts.Length > 0); } public IEnumerable<User> GetEnabledUsers() @@ -2217,9 +2196,9 @@ 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"), parts[0], StringComparison.OrdinalIgnoreCase)); + var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), parts[0], StringComparison.OrdinalIgnoreCase)); if (service == null) { @@ -2248,7 +2227,7 @@ namespace Emby.Server.Implementations.LiveTv public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) { - info = _jsonSerializer.DeserializeFromString<TunerHostInfo>(_jsonSerializer.SerializeToString(info)); + info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.SerializeToUtf8Bytes(info)); var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); @@ -2269,7 +2248,7 @@ namespace Emby.Server.Implementations.LiveTv if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) { - info.Id = Guid.NewGuid().ToString("N"); + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); list.Add(info); config.TunerHosts = list.ToArray(); } @@ -2282,7 +2261,7 @@ namespace Emby.Server.Implementations.LiveTv if (dataSourceChanged) { - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); } return info; @@ -2292,15 +2271,17 @@ 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.DeserializeFromString<ListingsProviderInfo>(_jsonSerializer.SerializeToString(info)); + info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.SerializeToUtf8Bytes(info)); var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); if (provider == null) { throw new ResourceNotFoundException( - string.Format("Couldn't find provider of type: '{0}'", info.Type) - ); + string.Format( + CultureInfo.InvariantCulture, + "Couldn't find provider of type: '{0}'", + info.Type)); } await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); @@ -2312,7 +2293,7 @@ namespace Emby.Server.Implementations.LiveTv if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) { - info.Id = Guid.NewGuid().ToString("N"); + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); list.Add(info); config.ListingProviders = list.ToArray(); } @@ -2323,7 +2304,7 @@ namespace Emby.Server.Implementations.LiveTv _config.SaveConfiguration("livetv", config); - _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>(); return info; } @@ -2335,23 +2316,23 @@ 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) + public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) { var config = GetConfiguration(); var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelId, StringComparison.OrdinalIgnoreCase)).ToArray(); + listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); - if (!string.Equals(tunerChannelId, providerChannelId, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) { var list = listingsProviderInfo.ChannelMappings.ToList(); list.Add(new NameValuePair { - Name = tunerChannelId, - Value = providerChannelId + Name = tunerChannelNumber, + Value = providerChannelNumber }); listingsProviderInfo.ChannelMappings = list.ToArray(); } @@ -2369,12 +2350,12 @@ 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)); + return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); } - public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> epgChannels) + public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> providerChannels) { var result = new TunerChannelMapping { @@ -2387,7 +2368,7 @@ namespace Emby.Server.Implementations.LiveTv result.Name = tunerChannel.Number + " " + result.Name; } - var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, epgChannels); + var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, providerChannels); if (providerChannel != null) { @@ -2462,20 +2443,18 @@ namespace Emby.Server.Implementations.LiveTv .SelectMany(i => i.Locations) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(i => _libraryManager.FindByPath(i, true)) - .Where(i => i != null) - .Where(i => i.IsVisibleStandalone(user)) + .Where(i => i != null && i.IsVisibleStandalone(user)) .SelectMany(i => _libraryManager.GetCollectionFolders(i)) .GroupBy(x => x.Id) .Select(x => x.First()) .OrderBy(i => i.SortName) .ToList(); - folders.AddRange(_channelManager().GetChannelsInternal(new MediaBrowser.Model.Channels.ChannelQuery + folders.AddRange(_channelManager.GetChannelsInternal(new MediaBrowser.Model.Channels.ChannelQuery { UserId = user.Id, IsRecordingsFolder = true, RefreshLatestChannelItems = refreshChannels - }).Items); return folders.Cast<BaseItem>().ToList(); diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs index cd1731de5..ecd28097d 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -1,63 +1,56 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv { public class LiveTvMediaSourceProvider : IMediaSourceProvider { + // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. + private const char StreamIdDelimiter = '_'; + private readonly ILiveTvManager _liveTvManager; - private readonly IJsonSerializer _jsonSerializer; - private readonly ILogger _logger; + private readonly ILogger<LiveTvMediaSourceProvider> _logger; private readonly IMediaSourceManager _mediaSourceManager; - private readonly IMediaEncoder _mediaEncoder; private readonly IServerApplicationHost _appHost; - private IApplicationPaths _appPaths; - public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IApplicationPaths appPaths, IJsonSerializer jsonSerializer, ILoggerFactory loggerFactory, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IServerApplicationHost appHost) + public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost) { _liveTvManager = liveTvManager; - _jsonSerializer = jsonSerializer; + _logger = logger; _mediaSourceManager = mediaSourceManager; - _mediaEncoder = mediaEncoder; _appHost = appHost; - _logger = loggerFactory.CreateLogger(GetType().Name); - _appPaths = appPaths; } public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken) { - var baseItem = (BaseItem)item; - - if (baseItem.SourceType == SourceType.LiveTV) + if (item.SourceType == SourceType.LiveTV) { var activeRecordingInfo = _liveTvManager.GetActiveRecordingInfo(item.Path); - if (string.IsNullOrEmpty(baseItem.Path) || activeRecordingInfo != null) + if (string.IsNullOrEmpty(item.Path) || activeRecordingInfo != null) { return GetMediaSourcesInternal(item, activeRecordingInfo, cancellationToken); } } - return Task.FromResult<IEnumerable<MediaSourceInfo>>(Array.Empty<MediaSourceInfo>()); + return Task.FromResult(Enumerable.Empty<MediaSourceInfo>()); } - // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. - private const char StreamIdDelimeter = '_'; - private const string StreamIdDelimeterString = "_"; - private async Task<IEnumerable<MediaSourceInfo>> GetMediaSourcesInternal(BaseItem item, ActiveRecordingInfo activeRecordingInfo, CancellationToken cancellationToken) { IEnumerable<MediaSourceInfo> sources; @@ -85,12 +78,11 @@ namespace Emby.Server.Implementations.LiveTv } var list = sources.ToList(); - var serverUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); foreach (var source in list) { source.Type = MediaSourceType.Default; - source.BufferMs = source.BufferMs ?? 1500; + source.BufferMs ??= 1500; if (source.RequiresOpening || forceRequireOpening) { @@ -99,28 +91,32 @@ namespace Emby.Server.Implementations.LiveTv if (source.RequiresOpening) { - var openKeys = new List<string>(); - openKeys.Add(item.GetType().Name); - openKeys.Add(item.Id.ToString("N")); - openKeys.Add(source.Id ?? string.Empty); - source.OpenToken = string.Join(StreamIdDelimeterString, openKeys.ToArray()); + var openKeys = new List<string> + { + item.GetType().Name, + item.Id.ToString("N", CultureInfo.InvariantCulture), + source.Id ?? string.Empty + }; + + source.OpenToken = string.Join(StreamIdDelimiter, openKeys); } // 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); } } - _logger.LogDebug("MediaSources: {0}", _jsonSerializer.SerializeToString(list)); + _logger.LogDebug("MediaSources: {@MediaSources}", list); return list; } + /// <inheritdoc /> public async Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { - var keys = openToken.Split(new[] { StreamIdDelimeter }, 3); + var keys = openToken.Split(StreamIdDelimiter, 3); var mediaSourceId = keys.Length >= 3 ? keys[2] : null; var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs b/Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs index 542951de4..15df0dcf1 100644 --- a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs +++ b/Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.LiveTv; @@ -8,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[] { - + 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 } }; } @@ -49,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 715f600a1..2b82f2462 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -1,8 +1,12 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; @@ -12,53 +16,52 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts { public abstract class BaseTunerHost { - protected readonly IServerConfigurationManager Config; - protected readonly ILogger Logger; - protected IJsonSerializer JsonSerializer; - protected readonly IFileSystem FileSystem; - - private readonly ConcurrentDictionary<string, ChannelCache> _channelCache = - new ConcurrentDictionary<string, ChannelCache>(StringComparer.OrdinalIgnoreCase); + private readonly IMemoryCache _memoryCache; - protected BaseTunerHost(IServerConfigurationManager config, ILogger logger, IJsonSerializer jsonSerializer, IFileSystem fileSystem) + protected BaseTunerHost(IServerConfigurationManager config, ILogger<BaseTunerHost> logger, IFileSystem fileSystem, IMemoryCache memoryCache) { Config = config; Logger = logger; - JsonSerializer = jsonSerializer; + _memoryCache = memoryCache; FileSystem = fileSystem; } + protected IServerConfigurationManager Config { get; } + + protected ILogger<BaseTunerHost> Logger { get; } + + protected IFileSystem FileSystem { get; } + public virtual bool IsSupported => true; - protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); public abstract string Type { get; } + protected virtual string ChannelIdPrefix => Type + "_"; + + protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); + public async Task<List<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken) { - ChannelCache cache = null; var key = tuner.Id; - if (enableCache && !string.IsNullOrEmpty(key) && _channelCache.TryGetValue(key, out cache)) + if (enableCache && !string.IsNullOrEmpty(key) && _memoryCache.TryGetValue(key, out List<ChannelInfo> cache)) { - return cache.Channels.ToList(); + return cache; } - var result = await GetChannelsInternal(tuner, cancellationToken).ConfigureAwait(false); - var list = result.ToList(); - //logger.LogInformation("Channels from {0}: {1}", tuner.Url, JsonSerializer.SerializeToString(list)); + var list = await GetChannelsInternal(tuner, cancellationToken).ConfigureAwait(false); + // logger.LogInformation("Channels from {0}: {1}", tuner.Url, JsonSerializer.SerializeToString(list)); if (!string.IsNullOrEmpty(key) && list.Count > 0) { - cache = cache ?? new ChannelCache(); - cache.Channels = list; - _channelCache.AddOrUpdate(key, cache, (k, v) => cache); + _memoryCache.Set(key, list); } return list; @@ -93,11 +96,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts try { Directory.CreateDirectory(Path.GetDirectoryName(channelCacheFile)); - JsonSerializer.SerializeToFile(channels, channelCacheFile); + await using var writeStream = AsyncFile.OpenWrite(channelCacheFile); + await JsonSerializer.SerializeAsync(writeStream, channels, cancellationToken: cancellationToken).ConfigureAwait(false); } catch (IOException) { - } } } @@ -109,12 +112,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { try { - var channels = JsonSerializer.DeserializeFromFile<List<ChannelInfo>>(channelCacheFile); + await using var readStream = AsyncFile.OpenRead(channelCacheFile); + var channels = await JsonSerializer.DeserializeAsync<List<ChannelInfo>>(readStream, cancellationToken: cancellationToken) + .ConfigureAwait(false); list.AddRange(channels); } catch (IOException) { - } } } @@ -158,7 +162,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return new List<MediaSourceInfo>(); } - protected abstract Task<ILiveStream> GetChannelStream(TunerHostInfo tuner, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); + protected abstract Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken); public async Task<ILiveStream> GetChannelStream(string channelId, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { @@ -217,8 +221,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts throw new LiveTvConflictException(); } - protected virtual string ChannelIdPrefix => Type + "_"; - protected virtual bool IsValidChannelId(string channelId) { if (string.IsNullOrEmpty(channelId)) @@ -233,10 +235,5 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { return Config.GetConfiguration<LiveTvOptions>("livetv"); } - - private class ChannelCache - { - public List<ChannelInfo> Channels; - } } } 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/HdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs new file mode 100644 index 000000000..069b4fab6 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs @@ -0,0 +1,35 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class HdHomerunChannelCommands : IHdHomerunChannelCommands + { + private string? _channel; + private string? _profile; + + public HdHomerunChannelCommands(string? channel, string? profile) + { + _channel = channel; + _profile = profile; + } + + public IEnumerable<(string, string)> GetCommands() + { + if (!string.IsNullOrEmpty(_channel)) + { + if (!string.IsNullOrEmpty(_profile) + && !string.Equals(_profile, "native", StringComparison.OrdinalIgnoreCase)) + { + yield return ("vchannel", $"{_channel} transcode={_profile}"); + } + else + { + yield return ("vchannel", _channel); + } + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 24b100edd..78ea7bd0f 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -1,123 +1,113 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; +using System.Net; +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.Controller.MediaEncoding; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.System; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; 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 logger, - IJsonSerializer jsonSerializer, + ILogger<HdHomerunHost> logger, IFileSystem fileSystem, - IHttpClient httpClient, + IHttpClientFactory httpClientFactory, IServerApplicationHost appHost, ISocketFactory socketFactory, - INetworkManager networkManager) - : base(config, logger, jsonSerializer, fileSystem) + IStreamHelper streamHelper, + 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"; - public override string Type => DeviceType; - - public static string DeviceType => "hdhomerun"; + public override string Type => "hdhomerun"; protected override string ChannelIdPrefix => "hdhr_"; - private string GetChannelId(TunerHostInfo info, Channels i) - { - var id = ChannelIdPrefix + i.GuideNumber; + private string GetChannelId(Channels i) + => ChannelIdPrefix + i.GuideNumber; - return id; - } - - 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, "GET").ConfigureAwait(false)) - { - using (var stream = response.Content) - { - var lineup = await JsonSerializer.DeserializeFromStreamAsync<List<Channels>>(stream).ConfigureAwait(false) ?? new List<Channels>(); - - if (info.ImportFavoritesOnly) - { - lineup = lineup.Where(i => i.Favorite).ToList(); - } + 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>(); - return lineup.Where(i => !i.DRM).ToList(); - } + if (info.ImportFavoritesOnly) + { + lineup = lineup.Where(i => i.Favorite).ToList(); } - } - private class HdHomerunChannelInfo : ChannelInfo - { - public bool IsLegacyTuner { get; set; } + return lineup.Where(i => !i.DRM).ToList(); } - protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken) + protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken) { - var lineup = await GetLineup(info, cancellationToken).ConfigureAwait(false); + var lineup = await GetLineup(tuner, cancellationToken).ConfigureAwait(false); return lineup.Select(i => new HdHomerunChannelInfo { Name = i.GuideName, Number = i.GuideNumber, - Id = GetChannelId(info, i), + Id = GetChannelId(i), IsFavorite = i.Favorite, - TunerHostId = info.Id, - IsHD = i.HD == 1, + TunerHostId = tuner.Id, + IsHD = i.HD, AudioCodec = i.AudioCodec, VideoCodec = i.VideoCodec, ChannelType = ChannelType.TV, IsLegacyTuner = (i.URL ?? string.Empty).StartsWith("hdhomerun", StringComparison.OrdinalIgnoreCase), Path = i.URL - }).Cast<ChannelInfo>().ToList(); } - private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>(); - 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; @@ -134,39 +124,32 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun try { - using (var response = await _httpClient.SendAsync(new HttpRequestOptions() - { - Url = string.Format("{0}/discover.json", GetApiUrl(info)), - CancellationToken = cancellationToken, - TimeoutMs = Convert.ToInt32(TimeSpan.FromSeconds(10).TotalMilliseconds), - BufferContent = false + 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); - }, "GET").ConfigureAwait(false)) + if (!string.IsNullOrEmpty(cacheKey)) { - using (var stream = response.Content) + lock (_modelCache) { - var discoverResponse = await JsonSerializer.DeserializeFromStreamAsync<DiscoverResponse>(stream).ConfigureAwait(false); - - if (!string.IsNullOrEmpty(cacheKey)) - { - lock (_modelCache) - { - _modelCache[cacheKey] = discoverResponse; - } - } - - return discoverResponse; + _modelCache[cacheKey] = discoverResponse; } } + + return discoverResponse; } - catch (HttpException ex) + catch (HttpRequestException ex) { - if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound) + if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) { - var defaultValue = "HDHR"; + const string DefaultValue = "HDHR"; var response = new DiscoverResponse { - ModelNumber = defaultValue + ModelNumber = DefaultValue }; if (!string.IsNullOrEmpty(cacheKey)) { @@ -176,6 +159,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun _modelCache[cacheKey] = response; } } + return response; } @@ -187,43 +171,50 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - using (var stream = await _httpClient.Get(new HttpRequestOptions() - { - Url = string.Format("{0}/tuners.html", GetApiUrl(info)), - CancellationToken = cancellationToken, - TimeoutMs = Convert.ToInt32(TimeSpan.FromSeconds(5).TotalMilliseconds), - BufferContent = false - })) + 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)) { - var tuners = new List<LiveTvTunerInfo>(); - using (var sr = new StreamReader(stream, System.Text.Encoding.UTF8)) + string stripedLine = StripXML(line); + if (stripedLine.Contains("Channel", StringComparison.Ordinal)) { - while (!sr.EndOfStream) + 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)) { - string line = StripXML(sr.ReadLine()); - if (line.Contains("Channel")) - { - 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; } + + tuners.Add(new LiveTvTunerInfo + { + Name = name, + SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, + ProgramName = currentChannel, + Status = status + }); } - return tuners; } + + return tuners; } private static string StripXML(string source) { + if (string.IsNullOrEmpty(source)) + { + return string.Empty; + } + char[] buffer = new char[source.Length]; int bufferIndex = 0; bool inside = false; @@ -236,17 +227,19 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun inside = true; continue; } + if (let == '>') { inside = false; continue; } + if (!inside) { - buffer[bufferIndex] = let; - bufferIndex++; + buffer[bufferIndex++] = let; } } + return new string(buffer, 0, bufferIndex); } @@ -254,19 +247,19 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); - var tuners = new List<LiveTvTunerInfo>(); + var tuners = new List<LiveTvTunerInfo>(model.TunerCount); var uri = new Uri(GetApiUrl(info)); - using (var manager = new HdHomerunManager(_socketFactory, Logger)) + using (var manager = new HdHomerunManager()) { // Legacy HdHomeruns are IPv4 only - var ipInfo = _networkManager.ParseIpAddress(uri.Host); + var ipInfo = IPAddress.Parse(uri.Host); - for (int i = 0; i < model.TunerCount; ++i) + for (int i = 0; i < model.TunerCount; i++) { - var name = string.Format("Tuner {0}", i + 1); - var currentChannel = "none"; /// @todo Get current channel and map back to Station Id + var name = string.Format(CultureInfo.InvariantCulture, "Tuner {0}", i + 1); + var currentChannel = "none"; // TODO: Get current channel and map back to Station Id var isAvailable = await manager.CheckTunerAvailability(ipInfo, i, cancellationToken).ConfigureAwait(false); var status = isAvailable ? LiveTvTunerStatus.Available : LiveTvTunerStatus.LiveTv; tuners.Add(new LiveTvTunerInfo @@ -278,6 +271,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun }); } } + return tuners; } @@ -304,7 +298,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public async Task<List<LiveTvTunerInfo>> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken) { // TODO Need faster way to determine UDP vs HTTP - var channels = await GetChannels(info, true, cancellationToken); + var channels = await GetChannels(info, true, cancellationToken).ConfigureAwait(false); var hdHomerunChannelInfo = channels.FirstOrDefault() as HdHomerunChannelInfo; @@ -333,23 +327,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]; @@ -436,12 +413,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { videoCodec = channelInfo.VideoCodec; } + string audioCodec = channelInfo.AudioCodec; - if (!videoBitrate.HasValue) - { - videoBitrate = isHd ? 15000000 : 2000000; - } + videoBitrate ??= isHd ? 15000000 : 2000000; + int? audioBitrate = isHd ? 448000 : 192000; // normalize @@ -463,36 +439,36 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { id = "native"; } - id += "_" + channelId.GetMD5().ToString("N") + "_" + url.GetMD5().ToString("N"); + + id += "_" + channelId.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_" + url.GetMD5().ToString("N", CultureInfo.InvariantCulture); var mediaSource = new MediaSourceInfo { Path = url, Protocol = MediaProtocol.Udp, MediaStreams = new List<MediaStream> - { - new MediaStream - { - Type = MediaStreamType.Video, - // Set the index to -1 because we don't know the exact index of the video stream within the container - Index = -1, - IsInterlaced = isInterlaced, - Codec = videoCodec, - Width = width, - Height = height, - BitRate = videoBitrate, - NalLengthSize = nal - - }, - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1, - Codec = audioCodec, - BitRate = audioBitrate - } - }, + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + IsInterlaced = isInterlaced, + Codec = videoCodec, + Width = width, + Height = height, + BitRate = videoBitrate, + NalLengthSize = nal + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1, + Codec = audioCodec, + BitRate = audioBitrate + } + }, RequiresOpening = true, RequiresClosing = true, BufferMs = 0, @@ -503,8 +479,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun SupportsTranscoding = true, IsInfiniteStream = true, IgnoreDts = true, - //IgnoreIndex = true, - //ReadAtNativeFramerate = true + // IgnoreIndex = true, + // ReadAtNativeFramerate = true }; mediaSource.InferTotalBitrate(); @@ -512,79 +488,91 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return mediaSource; } - protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, ChannelInfo channelInfo, CancellationToken cancellationToken) + protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken) { var list = new List<MediaSourceInfo>(); - var channelId = channelInfo.Id; + var channelId = channel.Id; var hdhrId = GetHdHrIdFromChannelId(channelId); - var hdHomerunChannelInfo = channelInfo as HdHomerunChannelInfo; - - var isLegacyTuner = hdHomerunChannelInfo != null && hdHomerunChannelInfo.IsLegacyTuner; - - if (isLegacyTuner) + if (channel is HdHomerunChannelInfo hdHomerunChannelInfo && hdHomerunChannelInfo.IsLegacyTuner) { - list.Add(GetMediaSource(info, hdhrId, channelInfo, "native")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); } else { - try - { - var modelInfo = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); + var modelInfo = await GetModelInfo(tuner, false, cancellationToken).ConfigureAwait(false); - if (modelInfo != null && modelInfo.SupportsTranscoding) + if (modelInfo != null && modelInfo.SupportsTranscoding) + { + if (tuner.AllowHWTranscoding) { - if (info.AllowHWTranscoding) - { - list.Add(GetMediaSource(info, hdhrId, channelInfo, "heavy")); - - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet540")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet480")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet360")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "internet240")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "mobile")); - } + list.Add(GetMediaSource(tuner, hdhrId, channel, "heavy")); - list.Add(GetMediaSource(info, hdhrId, channelInfo, "native")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet540")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet480")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet360")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "internet240")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "mobile")); } - } - catch - { + list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); } if (list.Count == 0) { - list.Add(GetMediaSource(info, hdhrId, channelInfo, "native")); + list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); } } return list; } - protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo info, ChannelInfo channelInfo, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) + protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { - var profile = streamId.Split('_')[0]; + var tunerCount = tunerHost.TunerCount; + + if (tunerCount > 0) + { + var tunerHostId = tunerHost.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.AsSpan().LeftPart('_').ToString(); - Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channelInfo.Id, streamId, profile); + Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channel.Id, streamId, profile); - var hdhrId = GetHdHrIdFromChannelId(channelInfo.Id); + var hdhrId = GetHdHrIdFromChannelId(channel.Id); - var hdhomerunChannel = channelInfo as HdHomerunChannelInfo; + var hdhomerunChannel = channel as HdHomerunChannelInfo; - var modelInfo = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); + var modelInfo = await GetModelInfo(tunerHost, false, cancellationToken).ConfigureAwait(false); if (!modelInfo.SupportsTranscoding) { profile = "native"; } - var mediaSource = GetMediaSource(info, hdhrId, channelInfo, profile); + var mediaSource = GetMediaSource(tunerHost, hdhrId, channel, profile); if (hdhomerunChannel != null && hdhomerunChannel.IsLegacyTuner) { - return new HdHomerunUdpStream(mediaSource, info, streamId, new LegacyHdHomerunChannelCommands(hdhomerunChannel.Path), modelInfo.TunerCount, FileSystem, _httpClient, Logger, Config.ApplicationPaths, _appHost, _socketFactory, _networkManager); + return new HdHomerunUdpStream( + mediaSource, + tunerHost, + streamId, + new LegacyHdHomerunChannelCommands(hdhomerunChannel.Path), + modelInfo.TunerCount, + FileSystem, + Logger, + Config, + _appHost, + _streamHelper); } var enableHttpStream = true; @@ -592,19 +580,39 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { mediaSource.Protocol = MediaProtocol.Http; - var httpUrl = channelInfo.Path; + var httpUrl = channel.Path; // If raw was used, the tuner doesn't support params if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) { httpUrl += "?transcode=" + profile; } - mediaSource.Path = httpUrl; - return new SharedHttpStream(mediaSource, info, streamId, FileSystem, _httpClient, Logger, Config.ApplicationPaths, _appHost); - } + mediaSource.Path = httpUrl; - return new HdHomerunUdpStream(mediaSource, info, streamId, new HdHomerunChannelCommands(hdhomerunChannel.Number, profile), modelInfo.TunerCount, FileSystem, _httpClient, Logger, Config.ApplicationPaths, _appHost, _socketFactory, _networkManager); + return new SharedHttpStream( + mediaSource, + tunerHost, + streamId, + FileSystem, + _httpClientFactory, + Logger, + Config, + _appHost, + _streamHelper); + } + + return new HdHomerunUdpStream( + mediaSource, + tunerHost, + streamId, + new HdHomerunChannelCommands(hdhomerunChannel.Number, profile), + modelInfo.TunerCount, + FileSystem, + Logger, + Config, + _appHost, + _streamHelper); } public async Task Validate(TunerHostInfo info) @@ -620,7 +628,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) { @@ -632,34 +640,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) @@ -667,7 +647,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 @@ -677,13 +659,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun // Need a way to set the Receive timeout on the socket otherwise this might never timeout? try { - await udpClient.SendToAsync(discBytes, 0, discBytes.Length, new IpEndPointInfo(new IpAddressInfo("255.255.255.255", IpAddressFamily.InterNetwork), 65001), cancellationToken); + await udpClient.SendToAsync(discBytes, 0, discBytes.Length, new IPEndPoint(IPAddress.Parse("255.255.255.255"), 65001), cancellationToken).ConfigureAwait(false); var receiveBuffer = new byte[8192]; while (!cancellationToken.IsCancellationRequested) { var response = await udpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, cancellationToken).ConfigureAwait(false); - var deviceIp = response.RemoteEndPoint.IpAddress.Address; + var deviceIp = response.RemoteEndPoint.Address.ToString(); // check to make sure we have enough bytes received to be a valid message and make sure the 2nd byte is the discover reply byte if (response.ReceivedBytes > 13 && response.Buffer[1] == 3) @@ -698,21 +680,21 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } } - } catch (OperationCanceledException) { } - catch + catch (Exception ex) { // Socket timeout indicates all messages have been received. + Logger.LogError(ex, "Error while sending discovery message"); } } return list; } - private async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken) + internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken) { var hostInfo = new TunerHostInfo { @@ -720,21 +702,18 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun Url = url }; - try - { - var modelInfo = await GetModelInfo(hostInfo, false, cancellationToken).ConfigureAwait(false); + var modelInfo = await GetModelInfo(hostInfo, false, cancellationToken).ConfigureAwait(false); - hostInfo.DeviceId = modelInfo.DeviceID; - hostInfo.FriendlyName = modelInfo.FriendlyName; + hostInfo.DeviceId = modelInfo.DeviceID; + hostInfo.FriendlyName = modelInfo.FriendlyName; + hostInfo.TunerCount = modelInfo.TunerCount; - return hostInfo; - } - catch - { - // logged at lower levels - } + return hostInfo; + } - return null; + private class HdHomerunChannelInfo : ChannelInfo + { + public bool IsLegacyTuner { get; set; } } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index 2205c0ecc..f9d151a81 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -1,203 +1,150 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; -using System.Collections.Generic; +using System.Buffers; +using System.Buffers.Binary; +using System.Globalization; using System.Net; +using System.Net.Sockets; using System.Text; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Net; -using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { - public interface IHdHomerunChannelCommands - { - IEnumerable<Tuple<string, string>> GetCommands(); - } - - public class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands - { - private string _channel; - private string _program; - public LegacyHdHomerunChannelCommands(string url) - { - // parse url for channel and program - var regExp = new Regex(@"\/ch(\d+)-?(\d*)"); - var match = regExp.Match(url); - if (match.Success) - { - _channel = match.Groups[1].Value; - _program = match.Groups[2].Value; - } - } - - public IEnumerable<Tuple<string, string>> GetCommands() - { - var commands = new List<Tuple<string, string>>(); - - if (!string.IsNullOrEmpty(_channel)) - commands.Add(Tuple.Create("channel", _channel)); - - if (!string.IsNullOrEmpty(_program)) - commands.Add(Tuple.Create("program", _program)); - return commands; - } - } - - public class HdHomerunChannelCommands : IHdHomerunChannelCommands + public sealed class HdHomerunManager : IDisposable { - private string _channel; - private string _profile; + public const int HdHomeRunPort = 65001; - public HdHomerunChannelCommands(string channel, string profile) - { - _channel = channel; - _profile = profile; - } - - public IEnumerable<Tuple<string, string>> GetCommands() - { - var commands = new List<Tuple<string, string>>(); - - if (!string.IsNullOrEmpty(_channel)) - { - if (!string.IsNullOrEmpty(_profile) && !string.Equals(_profile, "native", StringComparison.OrdinalIgnoreCase)) - { - commands.Add(Tuple.Create("vchannel", string.Format("{0} transcode={1}", _channel, _profile))); - } - else - { - commands.Add(Tuple.Create("vchannel", _channel)); - } - } - - return commands; - } - } - - public class HdHomerunManager : IDisposable - { - public static int HdHomeRunPort = 65001; // Message constants - private static byte GetSetName = 3; - private static byte GetSetValue = 4; - private static byte GetSetLockkey = 21; - private static ushort GetSetRequest = 4; - private static ushort GetSetReply = 5; + private const byte GetSetName = 3; + private const byte GetSetValue = 4; + private const byte GetSetLockkey = 21; + private const ushort GetSetRequest = 4; + private const ushort GetSetReply = 5; private uint? _lockkey = null; private int _activeTuner = -1; - private readonly ISocketFactory _socketFactory; - private IpAddressInfo _remoteIp; + private IPEndPoint _remoteEndPoint; - private ILogger _logger; - private ISocket _currentTcpSocket; - - public HdHomerunManager(ISocketFactory socketFactory, ILogger logger) - { - _socketFactory = socketFactory; - _logger = logger; - } + private TcpClient _tcpClient; public void Dispose() { - using (var socket = _currentTcpSocket) + using (var socket = _tcpClient) { if (socket != null) { - _currentTcpSocket = null; + _tcpClient = null; - var task = StopStreaming(socket); - Task.WaitAll(task); + StopStreaming(socket).GetAwaiter().GetResult(); } } - } - public async Task<bool> CheckTunerAvailability(IpAddressInfo remoteIp, int tuner, CancellationToken cancellationToken) - { - using (var socket = _socketFactory.CreateTcpSocket(remoteIp, HdHomeRunPort)) - { - return await CheckTunerAvailability(socket, remoteIp, tuner, cancellationToken).ConfigureAwait(false); - } + GC.SuppressFinalize(this); } - private static async Task<bool> CheckTunerAvailability(ISocket socket, IpAddressInfo remoteIp, int tuner, CancellationToken cancellationToken) + public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) { - var ipEndPoint = new IpEndPointInfo(remoteIp, HdHomeRunPort); + using var client = new TcpClient(); + client.Connect(remoteIp, HdHomeRunPort); - var lockkeyMsg = CreateGetMessage(tuner, "lockkey"); - await socket.SendToAsync(lockkeyMsg, 0, lockkeyMsg.Length, ipEndPoint, cancellationToken); + using var stream = client.GetStream(); + return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); + } - var receiveBuffer = new byte[8192]; - var response = await socket.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, cancellationToken).ConfigureAwait(false); + private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); + try + { + var msgLen = WriteGetMessage(buffer, tuner, "lockkey"); + await stream.WriteAsync(buffer.AsMemory(0, msgLen), cancellationToken).ConfigureAwait(false); - ParseReturnMessage(response.Buffer, response.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 + { + ArrayPool<byte>.Shared.Return(buffer); + } } - public async Task StartStreaming(IpAddressInfo remoteIp, IPAddress localIp, int localPort, IHdHomerunChannelCommands commands, int numTuners, CancellationToken cancellationToken) + public async Task StartStreaming(IPAddress remoteIp, IPAddress localIp, int localPort, IHdHomerunChannelCommands commands, int numTuners, CancellationToken cancellationToken) { - _remoteIp = remoteIp; - - var tcpClient = _socketFactory.CreateTcpSocket(_remoteIp, HdHomeRunPort); - _currentTcpSocket = tcpClient; + _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort); - var receiveBuffer = new byte[8192]; + _tcpClient = new TcpClient(); + _tcpClient.Connect(_remoteEndPoint); if (!_lockkey.HasValue) { - var rand = new Random(); - _lockkey = (uint)rand.Next(); + _lockkey = (uint)Random.Shared.Next(); } var lockKeyValue = _lockkey.Value; + var stream = _tcpClient.GetStream(); - var ipEndPoint = new IpEndPointInfo(_remoteIp, HdHomeRunPort); - - for (int i = 0; i < numTuners; ++i) + byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); + try { - if (!await CheckTunerAvailability(tcpClient, _remoteIp, i, cancellationToken).ConfigureAwait(false)) - continue; - - _activeTuner = i; - var lockKeyString = string.Format("{0:d}", lockKeyValue); - var lockkeyMsg = CreateSetMessage(i, "lockkey", lockKeyString, null); - await tcpClient.SendToAsync(lockkeyMsg, 0, lockkeyMsg.Length, ipEndPoint, cancellationToken).ConfigureAwait(false); - var response = await tcpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, cancellationToken).ConfigureAwait(false); - // parse response to make sure it worked - if (!ParseReturnMessage(response.Buffer, response.ReceivedBytes, out var returnVal)) - continue; - - var commandList = commands.GetCommands(); - foreach (Tuple<string, string> command in commandList) + for (int i = 0; i < numTuners; ++i) { - var channelMsg = CreateSetMessage(i, command.Item1, command.Item2, lockKeyValue); - await tcpClient.SendToAsync(channelMsg, 0, channelMsg.Length, ipEndPoint, cancellationToken).ConfigureAwait(false); - response = await tcpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, cancellationToken).ConfigureAwait(false); + if (!await CheckTunerAvailability(stream, i, cancellationToken).ConfigureAwait(false)) + { + continue; + } + + _activeTuner = i; + var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue); + 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(response.Buffer, response.ReceivedBytes, out returnVal)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { - await ReleaseLockkey(tcpClient, lockKeyValue).ConfigureAwait(false); continue; } - } + foreach (var command in commands.GetCommands()) + { + 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 (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) + { + await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false); + continue; + } + } - var targetValue = string.Format("rtp://{0}:{1}", localIp, localPort); - var targetMsg = CreateSetMessage(i, "target", targetValue, lockKeyValue); + var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIp, localPort); + var targetMsgLen = WriteSetMessage(buffer, i, "target", targetValue, lockKeyValue); - await tcpClient.SendToAsync(targetMsg, 0, targetMsg.Length, ipEndPoint, cancellationToken).ConfigureAwait(false); - response = await tcpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, cancellationToken).ConfigureAwait(false); - // parse response to make sure it worked - if (!ParseReturnMessage(response.Buffer, response.ReceivedBytes, out returnVal)) - { - await ReleaseLockkey(tcpClient, lockKeyValue).ConfigureAwait(false); - continue; - } + await stream.WriteAsync(buffer.AsMemory(0, targetMsgLen), cancellationToken).ConfigureAwait(false); + receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - return; + // parse response to make sure it worked + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) + { + await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false); + continue; + } + + return; + } + } + finally + { + ArrayPool<byte>.Shared.Return(buffer); } _activeTuner = -1; @@ -207,286 +154,201 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public async Task ChangeChannel(IHdHomerunChannelCommands commands, CancellationToken cancellationToken) { if (!_lockkey.HasValue) + { return; + } - using (var tcpClient = _socketFactory.CreateTcpSocket(_remoteIp, HdHomeRunPort)) - { - var commandList = commands.GetCommands(); - var receiveBuffer = new byte[8192]; + using var tcpClient = new TcpClient(); + tcpClient.Connect(_remoteEndPoint); - foreach (Tuple<string, string> command in commandList) + using var stream = tcpClient.GetStream(); + var commandList = commands.GetCommands(); + byte[] buffer = ArrayPool<byte>.Shared.Rent(8192); + try + { + foreach (var command in commandList) { - var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey); - await tcpClient.SendToAsync(channelMsg, 0, channelMsg.Length, new IpEndPointInfo(_remoteIp, HdHomeRunPort), cancellationToken).ConfigureAwait(false); - var response = await tcpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.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(response.Buffer, response.ReceivedBytes, out string returnVal)) + if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _)) { return; } } } + finally + { + ArrayPool<byte>.Shared.Return(buffer); + } } - public Task StopStreaming(ISocket socket) + public Task StopStreaming(TcpClient client) { var lockKey = _lockkey; if (!lockKey.HasValue) + { return Task.CompletedTask; + } - return ReleaseLockkey(socket, lockKey.Value); + return ReleaseLockkey(client, lockKey.Value); } - private async Task ReleaseLockkey(ISocket tcpClient, uint lockKeyValue) + private async Task ReleaseLockkey(TcpClient client, uint lockKeyValue) { - _logger.LogInformation("HdHomerunManager.ReleaseLockkey {0}", lockKeyValue); + var stream = client.GetStream(); - var ipEndPoint = new IpEndPointInfo(_remoteIp, HdHomeRunPort); - - var releaseTarget = CreateSetMessage(_activeTuner, "target", "none", lockKeyValue); - await tcpClient.SendToAsync(releaseTarget, 0, releaseTarget.Length, ipEndPoint, CancellationToken.None).ConfigureAwait(false); - - var receiveBuffer = new byte[8192]; - - await tcpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, CancellationToken.None).ConfigureAwait(false); - var releaseKeyMsg = CreateSetMessage(_activeTuner, "lockkey", "none", lockKeyValue); - _lockkey = null; - await tcpClient.SendToAsync(releaseKeyMsg, 0, releaseKeyMsg.Length, ipEndPoint, CancellationToken.None).ConfigureAwait(false); - await tcpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, CancellationToken.None).ConfigureAwait(false); + var buffer = ArrayPool<byte>.Shared.Rent(8192); + try + { + 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(buffer.AsMemory(0, releaseKeyMsgLen)).ConfigureAwait(false); + await stream.ReadAsync(buffer).ConfigureAwait(false); + } + finally + { + ArrayPool<byte>.Shared.Return(buffer); + } } - 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("/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]; - - int offset = InsertHeaderAndName(byteName, messageLength, message); - - bool flipEndian = BitConverter.IsLittleEndian; - - // calculate crc and insert at the end of the message - var crcBytes = BitConverter.GetBytes(HdHomerunCrc.GetCrc32(message, messageLength - 4)); - if (flipEndian) - Array.Reverse(crcBytes); - Buffer.BlockCopy(crcBytes, 0, message, offset, 4); - - return message; + var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name); + int offset = WriteHeaderAndPayload(buffer, byteName); + return FinishPacket(buffer, offset); } - private static byte[] CreateSetMessage(int tuner, string name, string value, uint? lockkey) + internal static int WriteSetMessage(Span<byte> buffer, int tuner, string name, string value, uint? lockkey) { - var byteName = Encoding.UTF8.GetBytes(string.Format("/tuner{0}/{1}\0", tuner, name)); - var byteValue = Encoding.UTF8.GetBytes(string.Format("{0}\0", value)); - - int messageLength = byteName.Length + byteValue.Length + 12; - if (lockkey.HasValue) - messageLength += 6; - - var message = new byte[messageLength]; - - int offset = InsertHeaderAndName(byteName, messageLength, message); + 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); - message[offset] = GetSetValue; - offset++; - message[offset] = Convert.ToByte(byteValue.Length); - offset++; - Buffer.BlockCopy(byteValue, 0, message, offset, byteValue.Length); - offset += byteValue.Length; if (lockkey.HasValue) { - message[offset] = GetSetLockkey; - offset++; - message[offset] = (byte)4; - offset++; - var lockKeyBytes = BitConverter.GetBytes(lockkey.Value); - if (flipEndian) - Array.Reverse(lockKeyBytes); - Buffer.BlockCopy(lockKeyBytes, 0, message, offset, 4); + buffer[offset++] = GetSetLockkey; + buffer[offset++] = 4; + BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(offset), lockkey.Value); 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); - Buffer.BlockCopy(crcBytes, 0, message, offset, 4); - - return message; + return FinishPacket(buffer, offset); } - private static int InsertHeaderAndName(byte[] byteName, int messageLength, byte[] message) + internal static int WriteNullTerminatedString(Span<byte> buffer, ReadOnlySpan<char> payload) { - // check to see if we need to flip endiannes - bool flipEndian = BitConverter.IsLittleEndian; - int offset = 0; + int len = Encoding.UTF8.GetBytes(payload, buffer.Slice(1)) + 1; - // 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 + // TODO: variable length: this can be 2 bytes if len > 127 + // Write length in front of value + buffer[0] = Convert.ToByte(len); - if (flipEndian) - { - Array.Reverse(getSetBytes); - Array.Reverse(msgLenBytes); - } + // null-terminate + buffer[len++] = 0; + + return len; + } + + private static int WriteHeaderAndPayload(Span<byte> buffer, ReadOnlySpan<char> payload) + { + // Packet type + BinaryPrimitives.WriteUInt16BigEndian(buffer, GetSetRequest); - // insert header bytes into message - Buffer.BlockCopy(getSetBytes, 0, message, offset, 2); - offset += 2; - Buffer.BlockCopy(msgLenBytes, 0, message, offset, 2); - offset += 2; + // We write the payload length at the end + int offset = 4; - // insert tag name and length - message[offset] = GetSetName; - offset++; - message[offset] = Convert.ToByte(byteName.Length); - offset++; + // Tag + buffer[offset++] = GetSetName; - // insert name string - Buffer.BlockCopy(byteName, 0, message, offset, byteName.Length); - offset += byteName.Length; + // Payload length + data + int strLen = WriteNullTerminatedString(buffer.Slice(offset), payload); + offset += strLen; return offset; } - private static bool ParseReturnMessage(byte[] buf, int numBytes, out string returnVal) + private static int FinishPacket(Span<byte> buffer, int offset) { - returnVal = string.Empty; + // Payload length + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(2), (ushort)(offset - 4)); - if (numBytes < 4) - return false; + // calculate crc and insert at the end of the message + var crc = Crc32.Compute(buffer.Slice(0, offset)); + BinaryPrimitives.WriteUInt32LittleEndian(buffer.Slice(offset), crc); - var flipEndian = BitConverter.IsLittleEndian; - int offset = 0; - byte[] msgTypeBytes = new byte[2]; - Buffer.BlockCopy(buf, offset, msgTypeBytes, 0, msgTypeBytes.Length); + return offset + 4; + } - if (flipEndian) - Array.Reverse(msgTypeBytes); + internal static bool VerifyReturnValueOfGetSet(ReadOnlySpan<byte> buffer, string expected) + { + return TryGetReturnValueOfGetSet(buffer, out var value) + && string.Equals(Encoding.UTF8.GetString(value), expected, StringComparison.OrdinalIgnoreCase); + } - var msgType = BitConverter.ToUInt16(msgTypeBytes, 0); - offset += 2; + internal static bool TryGetReturnValueOfGetSet(ReadOnlySpan<byte> buffer, out ReadOnlySpan<byte> value) + { + value = ReadOnlySpan<byte>.Empty; - if (msgType != GetSetReply) + if (buffer.Length < 8) + { return false; + } - byte[] msgLengthBytes = new byte[2]; - Buffer.BlockCopy(buf, offset, msgLengthBytes, 0, msgLengthBytes.Length); - if (flipEndian) - Array.Reverse(msgLengthBytes); + uint crc = BinaryPrimitives.ReadUInt32LittleEndian(buffer[^4..]); + if (crc != Crc32.Compute(buffer[..^4])) + { + return false; + } - var msgLength = BitConverter.ToUInt16(msgLengthBytes, 0); - offset += 2; + if (BinaryPrimitives.ReadUInt16BigEndian(buffer) != GetSetReply) + { + return false; + } - if (numBytes < msgLength + 8) + var msgLength = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2)); + if (buffer.Length != 2 + 2 + 4 + msgLength) + { return false; + } - var nameTag = buf[offset]; - offset++; + var offset = 4; + if (buffer[offset++] != GetSetName) + { + return false; + } - var nameLength = buf[offset]; - 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; - var valueTag = buf[offset]; - offset++; - - var valueLength = buf[offset]; - offset++; - - returnVal = Encoding.UTF8.GetString(buf, offset, valueLength - 1); // remove null terminator - return true; - } + if (buffer[offset++] != GetSetValue) + { + return false; + } - private 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) + var valueLength = buffer[offset++]; + if (buffer.Length < 4 + offset + valueLength) { - var hash = 0xffffffff; - for (var i = 0; i < numBytes; i++) - hash = (hash >> 8) ^ crc_table[(hash ^ bytes[i]) & 0xff]; - - var tmp = ~hash & 0xffffffff; - var b0 = tmp & 0xff; - var b1 = (tmp >> 8) & 0xff; - var b2 = (tmp >> 16) & 0xff; - var b3 = (tmp >> 24) & 0xff; - hash = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; - return hash; + 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 8774371d5..31445e1ec 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs @@ -1,11 +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.Net; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; @@ -18,30 +24,50 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { public class HdHomerunUdpStream : LiveStream, IDirectStreamProvider { - private readonly IServerApplicationHost _appHost; - private readonly MediaBrowser.Model.Net.ISocketFactory _socketFactory; + private const int RtpHeaderBytes = 12; + private readonly IServerApplicationHost _appHost; private readonly IHdHomerunChannelCommands _channelCommands; private readonly int _numTuners; - private readonly INetworkManager _networkManager; - public HdHomerunUdpStream(MediaSourceInfo mediaSource, TunerHostInfo tunerHostInfo, string originalStreamId, IHdHomerunChannelCommands channelCommands, int numTuners, IFileSystem fileSystem, IHttpClient httpClient, ILogger logger, IServerApplicationPaths appPaths, IServerApplicationHost appHost, MediaBrowser.Model.Net.ISocketFactory socketFactory, INetworkManager networkManager) - : base(mediaSource, tunerHostInfo, fileSystem, logger, appPaths) + public HdHomerunUdpStream( + MediaSourceInfo mediaSource, + TunerHostInfo tunerHostInfo, + string originalStreamId, + IHdHomerunChannelCommands channelCommands, + int numTuners, + IFileSystem fileSystem, + ILogger logger, + IConfigurationManager configurationManager, + IServerApplicationHost appHost, + IStreamHelper streamHelper) + : base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper) { _appHost = appHost; - _socketFactory = socketFactory; - _networkManager = networkManager; OriginalStreamId = originalStreamId; _channelCommands = channelCommands; _numTuners = numTuners; EnableStreamSharing = true; } - private static Socket CreateSocket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType) + /// <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 socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp); + 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 socket; + return Enumerable + .Range(range.Min, range.Max) + .FirstOrDefault(i => !udpListenerPorts.Contains(i)); } public override async Task Open(CancellationToken openCancellationToken) @@ -51,22 +77,22 @@ 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)); Logger.LogInformation("Opening HDHR UDP Live stream from {host}", uri.Host); var remoteAddress = IPAddress.Parse(uri.Host); - var embyRemoteAddress = _networkManager.ParseIpAddress(uri.Host); IPAddress localAddress = null; - using (var tcpSocket = CreateSocket(remoteAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp)) + using (var tcpClient = new TcpClient()) { try { - tcpSocket.Connect(new IPEndPoint(remoteAddress, HdHomerunManager.HdHomeRunPort)); - localAddress = ((IPEndPoint)tcpSocket.LocalEndPoint).Address; - tcpSocket.Close(); + await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort, openCancellationToken).ConfigureAwait(false); + localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address; + tcpClient.Close(); } catch (Exception ex) { @@ -75,237 +101,129 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - var udpClient = _socketFactory.CreateUdpSocket(localPort); - var hdHomerunManager = new HdHomerunManager(_socketFactory, Logger); + if (localAddress.IsIPv4MappedToIPv6) + { + localAddress = localAddress.MapToIPv4(); + } + + var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork); + var hdHomerunManager = new HdHomerunManager(); try { // send url to start streaming - await hdHomerunManager.StartStreaming(embyRemoteAddress, localAddress, localPort, _channelCommands, _numTuners, openCancellationToken).ConfigureAwait(false); + await hdHomerunManager.StartStreaming( + remoteAddress, + localAddress, + localPort, + _channelCommands, + _numTuners, + openCancellationToken).ConfigureAwait(false); } catch (Exception ex) { using (udpClient) using (hdHomerunManager) { - if (!(ex is OperationCanceledException)) + if (ex is not OperationCanceledException) { Logger.LogError(ex, "Error opening live stream:"); } + throw; } } var taskCompletionSource = new TaskCompletionSource<bool>(); - await StartStreaming(udpClient, hdHomerunManager, remoteAddress, taskCompletionSource, LiveStreamCancellationTokenSource.Token); + _ = StartStreaming( + udpClient, + hdHomerunManager, + remoteAddress, + taskCompletionSource, + LiveStreamCancellationTokenSource.Token); - //OpenedMediaSource.Protocol = MediaProtocol.File; - //OpenedMediaSource.Path = tempFile; - //OpenedMediaSource.ReadAtNativeFramerate = true; + // OpenedMediaSource.Protocol = MediaProtocol.File; + // OpenedMediaSource.Path = tempFile; + // OpenedMediaSource.ReadAtNativeFramerate = true; - MediaSource.Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; + MediaSource.Path = _appHost.GetLoopbackHttpApiUrl() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; MediaSource.Protocol = MediaProtocol.Http; - //OpenedMediaSource.SupportsDirectPlay = false; - //OpenedMediaSource.SupportsDirectStream = true; - //OpenedMediaSource.SupportsTranscoding = true; + // OpenedMediaSource.SupportsDirectPlay = false; + // OpenedMediaSource.SupportsDirectStream = true; + // OpenedMediaSource.SupportsTranscoding = true; - //await Task.Delay(5000).ConfigureAwait(false); + // await Task.Delay(5000).ConfigureAwait(false); await taskCompletionSource.Task.ConfigureAwait(false); } - private Task StartStreaming(MediaBrowser.Model.Net.ISocket udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) { - return Task.Run(async () => + 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); } - await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); - }); - } + EnableStreamSharing = false; + } - private static void Resolve(TaskCompletionSource<bool> openTaskCompletionSource) - { - Task.Run(() => - { - openTaskCompletionSource.TrySetResult(true); - }); + await DeleteTempFiles(TempFilePath).ConfigureAwait(false); } - private static int RtpHeaderBytes = 12; - private async Task CopyTo(MediaBrowser.Model.Net.ISocket udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) { - var bufferSize = 81920; - - byte[] buffer = new byte[bufferSize]; - int read; var resolved = false; - using (var source = _socketFactory.CreateNetworkStream(udpClient, false)) - using (var fileStream = FileSystem.GetFileStream(file, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, FileOpenOptions.None)) + using (var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Read)) { - var currentCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token).Token; - - while ((read = await source.ReadAsync(buffer, 0, buffer.Length, currentCancellationToken).ConfigureAwait(false)) != 0) + while (true) { cancellationToken.ThrowIfCancellationRequested(); - - currentCancellationToken = cancellationToken; - - read -= RtpHeaderBytes; - - if (read > 0) + using (var timeOutSource = new CancellationTokenSource()) + using (var linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeOutSource.Token)) { - fileStream.Write(buffer, RtpHeaderBytes, read); + var resTask = udpClient.ReceiveAsync(linkedSource.Token).AsTask(); + if (await Task.WhenAny(resTask, Task.Delay(30000, linkedSource.Token)).ConfigureAwait(false) != resTask) + { + resTask.Dispose(); + break; + } + + // We don't want all these delay tasks to keep running + timeOutSource.Cancel(); + var res = await resTask.ConfigureAwait(false); + var buffer = res.Buffer; + + var read = buffer.Length - RtpHeaderBytes; + + if (read > 0) + { + fileStream.Write(buffer, RtpHeaderBytes, read); + } + + if (!resolved) + { + resolved = true; + DateOpened = DateTime.UtcNow; + openTaskCompletionSource.TrySetResult(true); + } } - - if (!resolved) - { - resolved = true; - DateOpened = DateTime.UtcNow; - Resolve(openTaskCompletionSource); - } - } - } - } - - public class UdpClientStream : Stream - { - private static int RtpHeaderBytes = 12; - private static int PacketSize = 1316; - private readonly MediaBrowser.Model.Net.ISocket _udpClient; - bool disposed; - - public UdpClientStream(MediaBrowser.Model.Net.ISocket udpClient) : base() - { - _udpClient = udpClient; - } - - public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - if (buffer == null) - throw new ArgumentNullException(nameof(buffer)); - - if (offset + count < 0) - throw new ArgumentOutOfRangeException(nameof(offset), "offset + count must not be negative"); - - if (offset + count > buffer.Length) - throw new ArgumentException("offset + count must not be greater than the length of buffer"); - - if (disposed) - throw new ObjectDisposedException(nameof(UdpClientStream)); - - // This will always receive a 1328 packet size (PacketSize + RtpHeaderSize) - // The RTP header will be stripped so see how many reads we need to make to fill the buffer. - int numReads = count / PacketSize; - int totalBytesRead = 0; - byte[] receiveBuffer = new byte[81920]; - - for (int i = 0; i < numReads; ++i) - { - var data = await _udpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, cancellationToken).ConfigureAwait(false); - - var bytesRead = data.ReceivedBytes - RtpHeaderBytes; - - // remove rtp header - Buffer.BlockCopy(data.Buffer, RtpHeaderBytes, buffer, offset, bytesRead); - offset += bytesRead; - totalBytesRead += bytesRead; } - return totalBytesRead; - } - - public override int Read(byte[] buffer, int offset, int count) - { - if (buffer == null) - throw new ArgumentNullException(nameof(buffer)); - - if (offset + count < 0) - throw new ArgumentOutOfRangeException("offset + count must not be negative", "offset+count"); - - if (offset + count > buffer.Length) - throw new ArgumentException("offset + count must not be greater than the length of buffer"); - - if (disposed) - throw new ObjectDisposedException(nameof(UdpClientStream)); - - // This will always receive a 1328 packet size (PacketSize + RtpHeaderSize) - // The RTP header will be stripped so see how many reads we need to make to fill the buffer. - int numReads = count / PacketSize; - int totalBytesRead = 0; - byte[] receiveBuffer = new byte[81920]; - - for (int i = 0; i < numReads; ++i) - { - var receivedBytes = _udpClient.Receive(receiveBuffer, 0, receiveBuffer.Length); - - var bytesRead = receivedBytes - RtpHeaderBytes; - - // remove rtp header - Buffer.BlockCopy(receiveBuffer, RtpHeaderBytes, buffer, offset, bytesRead); - offset += bytesRead; - totalBytesRead += bytesRead; - } - return totalBytesRead; - } - - protected override void Dispose(bool disposing) - { - disposed = true; - } - - public override bool CanRead => throw new NotImplementedException(); - - public override bool CanSeek => throw new NotImplementedException(); - - public override bool CanWrite => throw new NotImplementedException(); - - public override long Length => throw new NotImplementedException(); - - public override long Position - { - get => throw new NotImplementedException(); - - set => throw new NotImplementedException(); - } - - public override void Flush() - { - throw new NotImplementedException(); - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotImplementedException(); - } - - public override void SetLength(long value) - { - throw new NotImplementedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotImplementedException(); } } } diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs new file mode 100644 index 000000000..153354932 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs @@ -0,0 +1,11 @@ +#pragma warning disable CS1591 + +using System.Collections.Generic; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public interface IHdHomerunChannelCommands + { + IEnumerable<(string, string)> GetCommands(); + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs new file mode 100644 index 000000000..26627b8aa --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs @@ -0,0 +1,38 @@ +#pragma warning disable CS1591 + +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands + { + private string? _channel; + private string? _program; + + public LegacyHdHomerunChannelCommands(string url) + { + // parse url for channel and program + var regExp = new Regex(@"\/ch([0-9]+)-?([0-9]*)"); + var match = regExp.Match(url); + if (match.Success) + { + _channel = match.Groups[1].Value; + _program = match.Groups[2].Value; + } + } + + public IEnumerable<(string, string)> GetCommands() + { + if (!string.IsNullOrEmpty(_channel)) + { + yield return ("channel", _channel); + } + + if (!string.IsNullOrEmpty(_program)) + { + yield return ("program", _program); + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs index 1f8ca276e..5581ba87c 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs @@ -1,10 +1,13 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; -using System.Collections.Generic; +using System.Globalization; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; @@ -15,51 +18,64 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { public class LiveStream : ILiveStream { - public MediaSourceInfo OriginalMediaSource { get; set; } - public MediaSourceInfo MediaSource { get; set; } - - public int ConsumerCount { get; set; } - - public string OriginalStreamId { get; set; } - public bool EnableStreamSharing { get; set; } - public string UniqueId { get; private set; } - - protected readonly IFileSystem FileSystem; - protected readonly IServerApplicationPaths AppPaths; - - protected string TempFilePath; - protected readonly ILogger Logger; - protected readonly CancellationTokenSource LiveStreamCancellationTokenSource = new CancellationTokenSource(); - - public string TunerHostId { get; private set; } - - public DateTime DateOpened { get; protected set; } - - public Func<Task> OnClose { get; set; } - - public LiveStream(MediaSourceInfo mediaSource, TunerHostInfo tuner, IFileSystem fileSystem, ILogger logger, IServerApplicationPaths appPaths) + private readonly IConfigurationManager _configurationManager; + + public LiveStream( + MediaSourceInfo mediaSource, + TunerHostInfo tuner, + IFileSystem fileSystem, + ILogger logger, + IConfigurationManager configurationManager, + IStreamHelper streamHelper) { OriginalMediaSource = mediaSource; FileSystem = fileSystem; MediaSource = mediaSource; Logger = logger; EnableStreamSharing = true; - UniqueId = Guid.NewGuid().ToString("N"); + UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); if (tuner != null) { TunerHostId = tuner.Id; } - AppPaths = appPaths; + _configurationManager = configurationManager; + StreamHelper = streamHelper; ConsumerCount = 1; SetTempFilePath("ts"); } + protected IFileSystem FileSystem { get; } + + protected IStreamHelper StreamHelper { get; } + + protected ILogger Logger { get; } + + protected CancellationTokenSource LiveStreamCancellationTokenSource { get; } = new CancellationTokenSource(); + + protected string TempFilePath { get; set; } + + public MediaSourceInfo OriginalMediaSource { get; set; } + + public MediaSourceInfo MediaSource { get; set; } + + public int ConsumerCount { get; set; } + + public string OriginalStreamId { get; set; } + + public bool EnableStreamSharing { get; set; } + + public string UniqueId { get; } + + public string TunerHostId { get; } + + public DateTime DateOpened { get; protected set; } + protected void SetTempFilePath(string extension) { - TempFilePath = Path.Combine(AppPaths.GetTranscodingTempPath(), UniqueId + "." + extension); + TempFilePath = Path.Combine(_configurationManager.GetTranscodePath(), UniqueId + "." + extension); } public virtual Task Open(CancellationToken openCancellationToken) @@ -72,164 +88,72 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { EnableStreamSharing = false; - Logger.LogInformation("Closing " + GetType().Name); + Logger.LogInformation("Closing {Type}", GetType().Name); LiveStreamCancellationTokenSource.Cancel(); - if (OnClose != null) - { - return CloseWithExternalFn(); - } - return Task.CompletedTask; } - private async Task CloseWithExternalFn() + public Stream GetStream() { - try - { - await OnClose().ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error closing live stream"); - } - } - - protected Stream GetInputStream(string path, bool allowAsyncFileRead) - { - var fileOpenOptions = FileOpenOptions.SequentialScan; - - if (allowAsyncFileRead) + var stream = GetInputStream(TempFilePath); + bool seekFile = (DateTime.UtcNow - DateOpened).TotalSeconds > 10; + if (seekFile) { - fileOpenOptions |= FileOpenOptions.Asynchronous; + TrySeek(stream, -20000); } - return FileSystem.GetFileStream(path, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.ReadWrite, fileOpenOptions); + return stream; } - public Task DeleteTempFiles() - { - return DeleteTempFiles(GetStreamFilePaths()); - } + protected FileStream GetInputStream(string path) + => new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + IODefaults.FileStreamBufferSize, + FileOptions.SequentialScan | FileOptions.Asynchronous); - protected async Task DeleteTempFiles(List<string> paths, int retryCount = 0) + protected async Task DeleteTempFiles(string path, int retryCount = 0) { if (retryCount == 0) { - Logger.LogInformation("Deleting temp files {0}", string.Join(", ", paths.ToArray())); - } - - var failedFiles = new List<string>(); - - foreach (var path in paths) - { - try - { - FileSystem.DeleteFile(path); - } - catch (DirectoryNotFoundException) - { - } - catch (FileNotFoundException) - { - } - catch (Exception ex) - { - Logger.LogError(ex, "Error deleting file {path}", path); - failedFiles.Add(path); - } + Logger.LogInformation("Deleting temp file {FilePath}", path); } - if (failedFiles.Count > 0 && retryCount <= 40) - { - await Task.Delay(500).ConfigureAwait(false); - await DeleteTempFiles(failedFiles, retryCount + 1).ConfigureAwait(false); - } - } - - protected virtual List<string> GetStreamFilePaths() - { - return new List<string> { TempFilePath }; - } - - public async Task CopyToAsync(Stream stream, CancellationToken cancellationToken) - { - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, LiveStreamCancellationTokenSource.Token).Token; - - var allowAsync = false; - // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 - - bool seekFile = (DateTime.UtcNow - DateOpened).TotalSeconds > 10; - - var nextFileInfo = GetNextFile(null); - var nextFile = nextFileInfo.Item1; - var isLastFile = nextFileInfo.Item2; - - while (!string.IsNullOrEmpty(nextFile)) + try { - var emptyReadLimit = isLastFile ? EmptyReadLimit : 1; - - await CopyFile(nextFile, seekFile, emptyReadLimit, allowAsync, stream, cancellationToken).ConfigureAwait(false); - - seekFile = false; - nextFileInfo = GetNextFile(nextFile); - nextFile = nextFileInfo.Item1; - isLastFile = nextFileInfo.Item2; + FileSystem.DeleteFile(path); } - - Logger.LogInformation("Live Stream ended."); - } - - private Tuple<string, bool> GetNextFile(string currentFile) - { - var files = GetStreamFilePaths(); - - //logger.LogInformation("Live stream files: {0}", string.Join(", ", files.ToArray())); - - if (string.IsNullOrEmpty(currentFile)) + catch (Exception ex) { - return new Tuple<string, bool>(files.Last(), true); + Logger.LogError(ex, "Error deleting file {FilePath}", path); + if (retryCount <= 40) + { + await Task.Delay(500).ConfigureAwait(false); + await DeleteTempFiles(path, retryCount + 1).ConfigureAwait(false); + } } - - var nextIndex = files.FindIndex(i => string.Equals(i, currentFile, StringComparison.OrdinalIgnoreCase)) + 1; - - var isLastFile = nextIndex == files.Count - 1; - - return new Tuple<string, bool>(files.ElementAtOrDefault(nextIndex), isLastFile); } - private async Task CopyFile(string path, bool seekFile, int emptyReadLimit, bool allowAsync, Stream stream, CancellationToken cancellationToken) + private void TrySeek(Stream stream, long offset) { - //logger.LogInformation("Opening live stream file {0}. Empty read limit: {1}", path, emptyReadLimit); - - using (var inputStream = (FileStream)GetInputStream(path, allowAsync)) + if (!stream.CanSeek) { - if (seekFile) - { - TrySeek(inputStream, -20000); - } - - await ApplicationHost.StreamHelper.CopyToAsync(inputStream, stream, 81920, emptyReadLimit, cancellationToken).ConfigureAwait(false); + return; } - } - - protected virtual int EmptyReadLimit => 1000; - private void TrySeek(FileStream stream, long offset) - { - //logger.LogInformation("TrySeek live stream"); try { stream.Seek(offset, SeekOrigin.End); } catch (IOException) { - } catch (ArgumentException) { - } catch (Exception ex) { diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index fdaaf0bae..08b9260b9 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -1,7 +1,13 @@ +#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.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Extensions; @@ -10,32 +16,50 @@ using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.System; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; 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; - - public M3UTunerHost(IServerConfigurationManager config, IMediaSourceManager mediaSourceManager, ILogger logger, IJsonSerializer jsonSerializer, IFileSystem fileSystem, IHttpClient httpClient, IServerApplicationHost appHost, INetworkManager networkManager) - : base(config, logger, jsonSerializer, fileSystem) + private readonly IStreamHelper _streamHelper; + + public M3UTunerHost( + IServerConfigurationManager config, + IMediaSourceManager mediaSourceManager, + ILogger<M3UTunerHost> logger, + IFileSystem fileSystem, + 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; + _streamHelper = streamHelper; } public override string Type => "m3u"; @@ -44,16 +68,16 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private string GetFullChannelIdPrefix(TunerHostInfo info) { - return ChannelIdPrefix + info.Url.GetMD5().ToString("N"); + return ChannelIdPrefix + info.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture); } - protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken) + protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken) { - var channelIdPrefix = GetFullChannelIdPrefix(info); + var channelIdPrefix = GetFullChannelIdPrefix(tuner); - var result = await new M3uParser(Logger, _httpClient, _appHost).Parse(info.Url, channelIdPrefix, info.Id, cancellationToken).ConfigureAwait(false); - - return result.Cast<ChannelInfo>().ToList(); + return await new M3uParser(Logger, _httpClientFactory) + .Parse(tuner, channelIdPrefix, cancellationToken) + .ConfigureAwait(false); } public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) @@ -64,7 +88,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts Name = Name, SourceType = Type, Status = LiveTvTunerStatus.Available, - Id = i.Url.GetMD5().ToString("N"), + Id = i.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture), Url = i.Url }) .ToList(); @@ -72,32 +96,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return Task.FromResult(list); } - private string[] _disallowedSharedStreamExtensions = new string[] + protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) { - ".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; + var tunerCount = tunerHost.TunerCount; if (tunerCount > 0) { - var tunerHostId = info.Id; - var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase)).ToList(); + var tunerHostId = tunerHost.Id; + var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase)); - if (liveStreams.Count >= tunerCount) + if (liveStreams.Count() >= tunerCount) { throw new LiveTvConflictException("M3U simultaneous stream limit has been reached."); } } - var sources = await GetChannelStreamMediaSources(info, channelInfo, cancellationToken).ConfigureAwait(false); + var sources = await GetChannelStreamMediaSources(tunerHost, channel, cancellationToken).ConfigureAwait(false); - var mediaSource = sources.First(); + var mediaSource = sources[0]; if (mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping) { @@ -105,24 +121,23 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (!_disallowedSharedStreamExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) { - return new SharedHttpStream(mediaSource, info, streamId, FileSystem, _httpClient, Logger, Config.ApplicationPaths, _appHost); + return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); } } - return new LiveStream(mediaSource, info, FileSystem, Logger, Config.ApplicationPaths); + return new LiveStream(mediaSource, tunerHost, FileSystem, Logger, Config, _streamHelper); } 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)) { - } } - protected override Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, ChannelInfo channelInfo, CancellationToken cancellationToken) + protected override Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken) { - return Task.FromResult(new List<MediaSourceInfo> { CreateMediaSourceInfo(info, channelInfo) }); + return Task.FromResult(new List<MediaSourceInfo> { CreateMediaSourceInfo(tuner, channel) }); } protected virtual MediaSourceInfo CreateMediaSourceInfo(TunerHostInfo info, ChannelInfo channel) @@ -145,7 +160,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (protocol == MediaProtocol.Http) { // Use user-defined user-agent. If there isn't one, make it look like a browser. - httpHeaders["User-Agent"] = string.IsNullOrWhiteSpace(info.UserAgent) ? + httpHeaders[HeaderNames.UserAgent] = string.IsNullOrWhiteSpace(info.UserAgent) ? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.85 Safari/537.36" : info.UserAgent; } @@ -176,7 +191,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts ReadAtNativeFramerate = false, - Id = channel.Path.GetMD5().ToString("N"), + Id = channel.Path.GetMD5().ToString("N", CultureInfo.InvariantCulture), IsInfiniteStream = true, IsRemote = isRemote, diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index ad124bb0f..506ef5548 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -1,107 +1,112 @@ +#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.Extensions; using MediaBrowser.Model.IO; +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 AsyncFile.OpenRead(info.Url); + } + + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url); + if (!string.IsNullOrEmpty(info.UserAgent)) + { + requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent); } - return Task.FromResult((Stream)File.OpenRead(url)); + + // 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); } - const string ExtInfPrefix = "#EXTINF:"; - 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 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"); + channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); } else { - channel.Id = channelIdPrefix + channel.Id.GetMD5().ToString("N"); + channel.Id = channelIdPrefix + channel.Id.GetMD5().ToString("N", CultureInfo.InvariantCulture); } - channel.Path = line; + channel.Path = trimmedLine; channels.Add(channel); - extInf = ""; + _logger.LogInformation("Parsed channel: {ChannelName}", channel.Name); + extInf = string.Empty; } } @@ -110,8 +115,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private ChannelInfo GetChannelnfo(string extInf, string tunerHostId, string mediaUrl) { - var channel = new ChannelInfo(); - channel.TunerHostId = tunerHostId; + var channel = new ChannelInfo() + { + TunerHostId = tunerHostId + }; extInf = extInf.Trim(); @@ -123,6 +130,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); @@ -137,13 +149,15 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { channelIdValues.Add(channelId); } + if (!string.IsNullOrWhiteSpace(tvgId)) { channelIdValues.Add(tvgId); } + if (channelIdValues.Count > 0) { - channel.Id = string.Join("_", channelIdValues.ToArray()); + channel.Id = string.Join('_', channelIdValues); } return channel; @@ -151,16 +165,15 @@ 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 nameInExtInf = nameParts.Length > 1 ? nameParts.Last().Trim() : null; + var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries); + var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty; string numberString = null; string attributeValue; - double doubleValue; if (attributes.TryGetValue("tvg-chno", out attributeValue)) { - if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out doubleValue)) + if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) { numberString = attributeValue; } @@ -170,41 +183,40 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { if (attributes.TryGetValue("tvg-id", out attributeValue)) { - if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out doubleValue)) + if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) { numberString = attributeValue; } else if (attributes.TryGetValue("channel-id", out attributeValue)) { - if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out doubleValue)) + if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) { numberString = attributeValue; } } } - if (String.IsNullOrWhiteSpace(numberString)) + 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 - if (!string.IsNullOrWhiteSpace(nameInExtInf)) + if (!nameInExtInf.IsEmpty && !nameInExtInf.IsWhiteSpace()) { var numberIndex = nameInExtInf.IndexOf(' '); if (numberIndex > 0) { - var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' }); + var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' }); - if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out var number)) + if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) { - numberString = numberPart; + numberString = numberPart.ToString(); } } } } - } if (!IsValidChannelNumber(numberString)) @@ -226,7 +238,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { try { - numberString = Path.GetFileNameWithoutExtension(mediaUrl.Split('/').Last()); + numberString = Path.GetFileNameWithoutExtension(mediaUrl.AsSpan().RightPart('/')).ToString(); if (!IsValidChannelNumber(numberString)) { @@ -253,7 +265,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts return false; } - if (!double.TryParse(numberString, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) + if (!double.TryParse(numberString, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) { return false; } @@ -263,8 +275,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 @@ -276,19 +288,19 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' }); - if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out var number)) + if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) { - //channel.Number = number.ToString(); + // channel.Number = number.ToString(); nameInExtInf = nameInExtInf.Substring(numberIndex + 1).Trim(new[] { ' ', '-' }); } } } - attributes.TryGetValue("tvg-name", out string name); + string name = nameInExtInf; if (string.IsNullOrWhiteSpace(name)) { - name = nameInExtInf; + attributes.TryGetValue("tvg-name", out name); } if (string.IsNullOrWhiteSpace(name)) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index d74cf3be2..3b69e55b0 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -1,8 +1,14 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; -using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; @@ -16,13 +22,22 @@ 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(MediaSourceInfo mediaSource, TunerHostInfo tunerHostInfo, string originalStreamId, IFileSystem fileSystem, IHttpClient httpClient, ILogger logger, IServerApplicationPaths appPaths, IServerApplicationHost appHost) - : base(mediaSource, tunerHostInfo, fileSystem, logger, appPaths) + public SharedHttpStream( + MediaSourceInfo mediaSource, + TunerHostInfo tunerHostInfo, + string originalStreamId, + IFileSystem fileSystem, + 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; @@ -39,105 +54,95 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath)); var typeName = GetType().Name; - Logger.LogInformation("Opening " + typeName + " Live stream from {0}", url); - - var httpRequestOptions = new HttpRequestOptions - { - Url = url, - CancellationToken = CancellationToken.None, - BufferContent = false, - - // Increase a little bit - TimeoutMs = 30000, - - EnableHttpCompression = false, - - LogResponse = true, - LogResponseHeaders = true - }; - - foreach (var header in mediaSource.RequiredHttpHeaders) - { - httpRequestOptions.RequestHeaders[header.Key] = header.Value; - } - - var response = await _httpClient.SendAsync(httpRequestOptions, "GET").ConfigureAwait(false); - - var extension = "ts"; - var requiresRemux = false; - - var contentType = response.ContentType ?? string.Empty; - if (contentType.IndexOf("matroska", StringComparison.OrdinalIgnoreCase) != -1) - { - requiresRemux = true; - } - else if (contentType.IndexOf("mp4", StringComparison.OrdinalIgnoreCase) != -1 || - contentType.IndexOf("dash", StringComparison.OrdinalIgnoreCase) != -1 || - contentType.IndexOf("mpegURL", StringComparison.OrdinalIgnoreCase) != -1 || - contentType.IndexOf("text/", StringComparison.OrdinalIgnoreCase) != -1) - { - requiresRemux = true; - } - - // Close the stream without any sharing features - if (requiresRemux) + Logger.LogInformation("Opening {StreamType} Live stream from {Url}", typeName, url); + + // Response stream is disposed manually. + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None) + .ConfigureAwait(false); + + var contentType = response.Content.Headers.ContentType?.ToString() ?? string.Empty; + if (contentType.Contains("matroska", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("mp4", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("dash", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("mpegURL", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("text/", StringComparison.OrdinalIgnoreCase)) { - using (response) - { - return; - } + // Close the stream without any sharing features + response.Dispose(); + return; } - SetTempFilePath(extension); + SetTempFilePath("ts"); var taskCompletionSource = new TaskCompletionSource<bool>(); - var now = DateTime.UtcNow; - _ = StartStreaming(response, taskCompletionSource, LiveStreamCancellationTokenSource.Token); - //OpenedMediaSource.Protocol = MediaProtocol.File; - //OpenedMediaSource.Path = tempFile; - //OpenedMediaSource.ReadAtNativeFramerate = true; + // OpenedMediaSource.Protocol = MediaProtocol.File; + // OpenedMediaSource.Path = tempFile; + // OpenedMediaSource.ReadAtNativeFramerate = true; - MediaSource.Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; + MediaSource.Path = _appHost.GetLoopbackHttpApiUrl() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; MediaSource.Protocol = MediaProtocol.Http; - //OpenedMediaSource.Path = TempFilePath; - //OpenedMediaSource.Protocol = MediaProtocol.File; + // OpenedMediaSource.Path = TempFilePath; + // OpenedMediaSource.Protocol = MediaProtocol.File; - //OpenedMediaSource.Path = _tempFilePath; - //OpenedMediaSource.Protocol = MediaProtocol.File; - //OpenedMediaSource.SupportsDirectPlay = false; - //OpenedMediaSource.SupportsDirectStream = true; - //OpenedMediaSource.SupportsTranscoding = true; + // OpenedMediaSource.Path = _tempFilePath; + // OpenedMediaSource.Protocol = MediaProtocol.File; + // OpenedMediaSource.SupportsDirectPlay = false; + // OpenedMediaSource.SupportsDirectStream = true; + // OpenedMediaSource.SupportsTranscoding = true; await taskCompletionSource.Task.ConfigureAwait(false); + if (taskCompletionSource.Task.Exception != null) + { + // Error happened while opening the stream so raise the exception again to inform the caller + throw taskCompletionSource.Task.Exception; + } + + if (!taskCompletionSource.Task.Result) + { + Logger.LogWarning("Zero bytes copied from stream {StreamType} to {FilePath} but no exception raised", GetType().Name, TempFilePath); + 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) + private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) { - return Task.Run(async () => - { - try + return Task.Run( + async () => { - Logger.LogInformation("Beginning {0} stream to {1}", GetType().Name, TempFilePath); - using (response) - using (var stream = response.Content) - using (var fileStream = FileSystem.GetFileStream(TempFilePath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, FileOpenOptions.None)) + try { - await ApplicationHost.StreamHelper.CopyToAsync(stream, fileStream, 81920, () => Resolve(openTaskCompletionSource), cancellationToken).ConfigureAwait(false); + Logger.LogInformation("Beginning {StreamType} stream to {FilePath}", GetType().Name, TempFilePath); + 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, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await StreamHelper.CopyToAsync( + stream, + fileStream, + IODefaults.CopyToBufferSize, + () => Resolve(openTaskCompletionSource), + cancellationToken).ConfigureAwait(false); } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - Logger.LogError(ex, "Error copying live stream."); - } - EnableStreamSharing = false; - await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false); - }); + catch (OperationCanceledException ex) + { + Logger.LogInformation("Copying of {StreamType} to {FilePath} was canceled", GetType().Name, TempFilePath); + openTaskCompletionSource.TrySetException(ex); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error copying live stream {StreamType} to {FilePath}", GetType().Name, TempFilePath); + openTaskCompletionSource.TrySetException(ex); + } + + openTaskCompletionSource.TrySetResult(false); + + EnableStreamSharing = false; + await DeleteTempFiles(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 new file mode 100644 index 000000000..18f17dda9 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -0,0 +1,123 @@ +{ + "Artists": "Kunstenare", + "Channels": "Kanale", + "Folders": "Lêergidse", + "Favorites": "Gunstelinge", + "HeaderFavoriteShows": "Gunsteling Vertonings", + "ValueSpecialEpisodeName": "Spesiale - {0}", + "HeaderAlbumArtists": "Kunstenaars se Album", + "Books": "Boeke", + "HeaderNextUp": "Volgende", + "Movies": "Flieks", + "Shows": "Televisie Reekse", + "HeaderContinueWatching": "Kyk Verder", + "HeaderFavoriteEpisodes": "Gunsteling Episodes", + "Photos": "Foto's", + "Playlists": "Snitlyste", + "HeaderFavoriteArtists": "Gunsteling Kunstenaars", + "HeaderFavoriteAlbums": "Gunsteling Albums", + "Sync": "Sinkroniseer", + "HeaderFavoriteSongs": "Gunsteling Liedjies", + "Songs": "Liedjies", + "DeviceOnlineWithName": "{0} is gekoppel", + "DeviceOfflineWithName": "{0} is ontkoppel", + "Collections": "Versamelings", + "Inherit": "Ontvang", + "HeaderLiveTV": "Lewendige TV", + "Application": "Program", + "AppDeviceValues": "App: {0}, Toestel: {1}", + "VersionNumber": "Weergawe {0}", + "ValueHasBeenAddedToLibrary": "{0} is by jou media biblioteek bygevoeg", + "UserStoppedPlayingItemWithValues": "{0} het klaar {1} op {2} gespeel", + "UserStartedPlayingItemWithValues": "{0} is besig om {1} op {2} te speel", + "UserPolicyUpdatedWithName": "Gebruiker beleid is verander vir {0}", + "UserPasswordChangedWithName": "Gebruiker {0} se wagwoord is verander", + "UserOnlineFromDevice": "{0} is aanlyn van {1}", + "UserOfflineFromDevice": "{0} is ontkoppel van {1}", + "UserLockedOutWithName": "Gebruiker {0} is uitgesluit", + "UserDownloadingItemWithValues": "{0} is besig om {1} af te laai", + "UserDeletedWithName": "Gebruiker {0} is verwyder", + "UserCreatedWithName": "Gebruiker {0} is geskep", + "User": "Gebruiker", + "TvShows": "TV Programme", + "System": "Stelsel", + "SubtitleDownloadFailureFromForItem": "Ondertitels het misluk om af te laai van {0} vir {1}", + "StartupEmbyServerIsLoading": "Jellyfin Bediener is besig om te laai. Probeer weer in 'n kort tyd.", + "ServerNameNeedsToBeRestarted": "{0} moet herbegin word", + "ScheduledTaskStartedWithName": "{0} het begin", + "ScheduledTaskFailedWithName": "{0} het misluk", + "ProviderValue": "Voorsiener: {0}", + "PluginUpdatedWithName": "{0} was opgedateer", + "PluginUninstalledWithName": "{0} was verwyder", + "PluginInstalledWithName": "{0} is geïnstalleer", + "Plugin": "Inprop module", + "NotificationOptionVideoPlaybackStopped": "Video terugspeel het gestop", + "NotificationOptionVideoPlayback": "Video terugspeel het begin", + "NotificationOptionUserLockedOut": "Gebruiker uitgeslyt", + "NotificationOptionTaskFailed": "Geskeduleerde taak het misluk", + "NotificationOptionServerRestartRequired": "Bediener herbegin nodig", + "NotificationOptionPluginUpdateInstalled": "Nuwe inprop module geïnstalleer", + "NotificationOptionPluginUninstalled": "Inprop module verwyder", + "NotificationOptionPluginInstalled": "Inprop module geïnstalleer", + "NotificationOptionPluginError": "Inprop module het misluk", + "NotificationOptionNewLibraryContent": "Nuwe inhoud bygevoeg", + "NotificationOptionInstallationFailed": "Installering het misluk", + "NotificationOptionCameraImageUploaded": "Kamera foto is opgelaai", + "NotificationOptionAudioPlaybackStopped": "Oudio terugspeel het gestop", + "NotificationOptionAudioPlayback": "Oudio terugspeel het begin", + "NotificationOptionApplicationUpdateInstalled": "Nuwe program weergawe geïnstalleer", + "NotificationOptionApplicationUpdateAvailable": "Nuwe program weergawe beskikbaar", + "NewVersionIsAvailable": "'n Nuwe Jellyfin Bedienaar weergawe kan afgelaai word.", + "NameSeasonUnknown": "Seisoen Onbekend", + "NameSeasonNumber": "Seisoen {0}", + "NameInstallFailed": "{0} installering het misluk", + "MusicVideos": "Musiek Videos", + "Music": "Musiek", + "MixedContent": "Gemengde inhoud", + "MessageServerConfigurationUpdated": "Bediener konfigurasie is opgedateer", + "MessageNamedServerConfigurationUpdatedWithValue": "Bediener konfigurasie seksie {0} is opgedateer", + "MessageApplicationUpdatedTo": "Jellyfin Bediener is opgedateer na {0}", + "MessageApplicationUpdated": "Jellyfin Bediener is opgedateer", + "Latest": "Nuutste", + "LabelRunningTimeValue": "Werktyd: {0}", + "LabelIpAddressValue": "IP adres: {0}", + "ItemRemovedWithName": "{0} is uit versameling verwyder", + "ItemAddedWithName": "{0} is by die versameling gevoeg", + "HomeVideos": "Tuis Videos", + "HeaderRecordingGroups": "Groep Opnames", + "Genres": "Genres", + "FailedLoginAttemptWithUserName": "Mislukte aansluiting van {0}", + "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", + "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 eb145b4fe..a83a453b4 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -1,61 +1,60 @@ { - "Albums": "الألبومات", - "AppDeviceValues": "التطبيق: {0}. الجهاز: {1}.", - "Application": "التطبيق", - "Artists": "الفنانون", - "AuthenticationSucceededWithUserName": "تم التأكد من {0} بنجاح", + "Albums": "ألبومات", + "AppDeviceValues": "تطبيق: {0}, جهاز: {1}", + "Application": "تطبيق", + "Artists": "الفنانين", + "AuthenticationSucceededWithUserName": "{0} سجل الدخول بنجاح", "Books": "الكتب", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "صورة كاميرا جديدة تم رفعها من {0}", "Channels": "القنوات", - "ChapterNameValue": "الباب {0}", - "Collections": "المجاميع", - "DeviceOfflineWithName": "تم قطع الاتصال بـ{0}", + "ChapterNameValue": "الفصل {0}", + "Collections": "التجميعات", + "DeviceOfflineWithName": "قُطِع الاتصال ب{0}", "DeviceOnlineWithName": "{0} متصل", "FailedLoginAttemptWithUserName": "عملية تسجيل الدخول فشلت من {0}", - "Favorites": "المفضلات", + "Favorites": "مفضلات", "Folders": "المجلدات", - "Genres": "أنواع الأفلام", - "HeaderAlbumArtists": "فنانو الألبومات", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "استئناف المشاهدة", + "Genres": "التضنيفات", + "HeaderAlbumArtists": "ألبوم الفنان", + "HeaderContinueWatching": "استمر بالمشاهدة", "HeaderFavoriteAlbums": "الألبومات المفضلة", "HeaderFavoriteArtists": "الفنانون المفضلون", "HeaderFavoriteEpisodes": "الحلقات المفضلة", "HeaderFavoriteShows": "المسلسلات المفضلة", "HeaderFavoriteSongs": "الأغاني المفضلة", "HeaderLiveTV": "التلفاز المباشر", - "HeaderNextUp": "التشغيل التالي", + "HeaderNextUp": "التالي", "HeaderRecordingGroups": "مجموعات التسجيل", - "HomeVideos": "الفيديوهات المنزلية", + "HomeVideos": "الفيديوهات الشخصية", "Inherit": "توريث", "ItemAddedWithName": "تم إضافة {0} للمكتبة", "ItemRemovedWithName": "تم إزالة {0} من المكتبة", "LabelIpAddressValue": "عنوان الآي بي: {0}", - "LabelRunningTimeValue": "وقت التشغيل: {0}", + "LabelRunningTimeValue": "المدة: {0}", "Latest": "الأحدث", - "MessageApplicationUpdated": "لقد تم تحديث خادم أمبي", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "MessageApplicationUpdated": "لقد تم تحديث خادم Jellyfin", + "MessageApplicationUpdatedTo": "تم تحديث خادم Jellyfin الى {0}", "MessageNamedServerConfigurationUpdatedWithValue": "تم تحديث إعدادات الخادم في قسم {0}", "MessageServerConfigurationUpdated": "تم تحديث إعدادات الخادم", - "MixedContent": "محتوى مخلوط", + "MixedContent": "محتوى مختلط", "Movies": "الأفلام", "Music": "الموسيقى", "MusicVideos": "الفيديوهات الموسيقية", - "NameInstallFailed": "{0} installation failed", + "NameInstallFailed": "فشل التثبيت {0}", "NameSeasonNumber": "الموسم {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NameSeasonUnknown": "الموسم غير معروف", + "NewVersionIsAvailable": "نسخة جديدة من خادم Jellyfin متوفرة للتحميل.", "NotificationOptionApplicationUpdateAvailable": "يوجد تحديث للتطبيق", "NotificationOptionApplicationUpdateInstalled": "تم تحديث التطبيق", "NotificationOptionAudioPlayback": "بدأ تشغيل المقطع الصوتي", "NotificationOptionAudioPlaybackStopped": "تم إيقاف تشغيل المقطع الصوتي", - "NotificationOptionCameraImageUploaded": "تم رقع صورة الكاميرا", - "NotificationOptionInstallationFailed": "عملية التنصيب فشلت", + "NotificationOptionCameraImageUploaded": "تم رفع صورة الكاميرا", + "NotificationOptionInstallationFailed": "فشل التثبيت", "NotificationOptionNewLibraryContent": "تم إضافة محتوى جديد", - "NotificationOptionPluginError": "فشل في الملحق", + "NotificationOptionPluginError": "فشل في البرنامج المضاف", "NotificationOptionPluginInstalled": "تم تثبيت الملحق", "NotificationOptionPluginUninstalled": "تمت إزالة الملحق", - "NotificationOptionPluginUpdateInstalled": "تم تحديث الملحق", + "NotificationOptionPluginUpdateInstalled": "تم تثبيت تحديثات الملحق", "NotificationOptionServerRestartRequired": "يجب إعادة تشغيل الخادم", "NotificationOptionTaskFailed": "فشل في المهمة المجدولة", "NotificationOptionUserLockedOut": "تم إقفال حساب المستخدم", @@ -70,28 +69,56 @@ "ProviderValue": "المزود: {0}", "ScheduledTaskFailedWithName": "العملية {0} فشلت", "ScheduledTaskStartedWithName": "تم بدء {0}", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", + "ServerNameNeedsToBeRestarted": "يحتاج لإعادة تشغيله {0}", + "Shows": "الحلقات", "Songs": "الأغاني", - "StartupEmbyServerIsLoading": "خادم أمبي قيد التحميل. الرجاء المحاوية بعد حين", + "StartupEmbyServerIsLoading": "خادم Jellyfin قيد التشغيل . الرجاء المحاولة بعد قليل.", "SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "تم إنزال الترجمات لـ {0}", + "SubtitleDownloadFailureFromForItem": "الترجمات فشلت في التحميل من {0} الى {1}", "Sync": "مزامنة", "System": "النظام", - "TvShows": "TV Shows", + "TvShows": "البرامج التلفزيونية", "User": "المستخدم", "UserCreatedWithName": "تم إنشاء المستخدم {0}", "UserDeletedWithName": "تم حذف المستخدم {0}", "UserDownloadingItemWithValues": "{0} يقوم بإنزال {1}", "UserLockedOutWithName": "المستخدم {0} تم منعه من الدخول", "UserOfflineFromDevice": "تم قطع اتصال {0} من {1}", - "UserOnlineFromDevice": "{0} متصلة عبر {1}", + "UserOnlineFromDevice": "{0} متصل عبر {1}", "UserPasswordChangedWithName": "تم تغيير كلمة السر للمستخدم {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", - "UserStartedPlayingItemWithValues": "قام {0} ببدء تشغيل {1}", - "UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserPolicyUpdatedWithName": "تم تحديث سياسة المستخدم {0}", + "UserStartedPlayingItemWithValues": "قام {0} ببدء تشغيل {1} على {2}", + "UserStoppedPlayingItemWithValues": "قام {0} بإيقاف تشغيل {1} على {2}", + "ValueHasBeenAddedToLibrary": "تمت اضافت {0} إلى مكتبة الوسائط", "ValueSpecialEpisodeName": "خاص - {0}", - "VersionNumber": "الإصدار رقم {0}" + "VersionNumber": "النسخة {0}", + "TaskCleanCacheDescription": "يحذف ملفات ذاكرة التخزين المؤقت التي لم يعد النظام بحاجة إليها.", + "TaskCleanCache": "احذف مجلد ذاكرة التخزين المؤقت", + "TasksChannelsCategory": "قنوات الإنترنت", + "TasksLibraryCategory": "مكتبة", + "TasksMaintenanceCategory": "صيانة", + "TaskRefreshLibraryDescription": "يقوم بفصح مكتبة الوسائط الخاصة بك بحثًا عن ملفات جديدة وتحديث البيانات الوصفية.", + "TaskRefreshLibrary": "افحص مكتبة الوسائط", + "TaskRefreshChapterImagesDescription": "إنشاء صور مصغرة لمقاطع الفيديو ذات فصول.", + "TaskRefreshChapterImages": "استخراج صور الفصل", + "TasksApplicationCategory": "تطبيق", + "TaskDownloadMissingSubtitlesDescription": "ابحث في الإنترنت على الترجمات المفقودة إستنادا على الميتاداتا.", + "TaskDownloadMissingSubtitles": "تحميل الترجمات المفقودة", + "TaskRefreshChannelsDescription": "تحديث معلومات قنوات الإنترنت.", + "TaskRefreshChannels": "إعادة تحديث القنوات", + "TaskCleanTranscodeDescription": "حذف ملفات الترميز الأقدم من يوم واحد.", + "TaskCleanTranscode": "حذف سجلات الترميز", + "TaskUpdatePluginsDescription": "تحميل وتثبيت الإضافات التي تم تفعيل التحديث التلقائي لها.", + "TaskUpdatePlugins": "تحديث الإضافات", + "TaskRefreshPeopleDescription": "تحديث البيانات الوصفية للممثلين والمخرجين في مكتبة الوسائط الخاصة بك.", + "TaskRefreshPeople": "إعادة تحميل الأشخاص", + "TaskCleanLogsDescription": "حذف السجلات الأقدم من {0} يوم.", + "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 a71dc9346..e1c923308 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -2,21 +2,20 @@ "Albums": "Албуми", "AppDeviceValues": "Програма: {0}, Устройство: {1}", "Application": "Програма", - "Artists": "Изпълнители", + "Artists": "Артисти", "AuthenticationSucceededWithUserName": "{0} се удостовери успешно", "Books": "Книги", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "Нова снимка от камера беше качена от {0}", "Channels": "Канали", "ChapterNameValue": "Глава {0}", "Collections": "Колекции", "DeviceOfflineWithName": "{0} се разкачи", "DeviceOnlineWithName": "{0} е свързан", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", + "FailedLoginAttemptWithUserName": "Неуспешен опит за влизане от {0}", "Favorites": "Любими", "Folders": "Папки", "Genres": "Жанрове", "HeaderAlbumArtists": "Изпълнители на албуми", - "HeaderCameraUploads": "Camera Uploads", "HeaderContinueWatching": "Продължаване на гледането", "HeaderFavoriteAlbums": "Любими албуми", "HeaderFavoriteArtists": "Любими изпълнители", @@ -25,26 +24,26 @@ "HeaderFavoriteSongs": "Любими песни", "HeaderLiveTV": "Телевизия на живо", "HeaderNextUp": "Следва", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Домашни клипове", + "HeaderRecordingGroups": "Запис групи", + "HomeVideos": "Домашни Клипове", "Inherit": "Наследяване", "ItemAddedWithName": "{0} е добавено към библиотеката", "ItemRemovedWithName": "{0} е премахнато от библиотеката", - "LabelIpAddressValue": "ИП адрес: {0}", - "LabelRunningTimeValue": "Running time: {0}", + "LabelIpAddressValue": "IP адрес: {0}", + "LabelRunningTimeValue": "Продължителност: {0}", "Latest": "Последни", - "MessageApplicationUpdated": "Сървърът е обновен", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", + "MessageApplicationUpdated": "Сървърът беше обновен", + "MessageApplicationUpdatedTo": "Сървърът беше обновен до {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Секцията {0} от сървърната конфигурация беше актуализирана", + "MessageServerConfigurationUpdated": "Конфигурацията на сървъра беше актуализирана", "MixedContent": "Смесено съдържание", "Movies": "Филми", "Music": "Музика", - "MusicVideos": "Музикални клипове", - "NameInstallFailed": "{0} installation failed", + "MusicVideos": "Музикални Видеа", + "NameInstallFailed": "{0} не можа да се инсталира", "NameSeasonNumber": "Сезон {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NameSeasonUnknown": "Неразпознат сезон", + "NewVersionIsAvailable": "Нова версия на Jellyfin сървъра е достъпна за сваляне.", "NotificationOptionApplicationUpdateAvailable": "Налично е обновление на програмата", "NotificationOptionApplicationUpdateInstalled": "Обновлението на програмата е инсталирано", "NotificationOptionAudioPlayback": "Възпроизвеждането на звук започна", @@ -56,42 +55,70 @@ "NotificationOptionPluginInstalled": "Приставката е инсталирана", "NotificationOptionPluginUninstalled": "Приставката е деинсталирана", "NotificationOptionPluginUpdateInstalled": "Обновлението на приставката е инсталирано", - "NotificationOptionServerRestartRequired": "Нужно е повторно пускане на сървъра", + "NotificationOptionServerRestartRequired": "Сървърът трябва да се рестартира", "NotificationOptionTaskFailed": "Грешка в планирана задача", - "NotificationOptionUserLockedOut": "User locked out", + "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} needs to be restarted", + "ServerNameNeedsToBeRestarted": "{0} трябва да се рестартира", "Shows": "Сериали", "Songs": "Песни", "StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.", "SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Изтеглени са субтитри за {0}", + "SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени", "Sync": "Синхронизиране", "System": "Система", "TvShows": "Телевизионни сериали", "User": "Потребител", "UserCreatedWithName": "Потребителят {0} е създаден", "UserDeletedWithName": "Потребителят {0} е изтрит", - "UserDownloadingItemWithValues": "{0} is downloading {1}", - "UserLockedOutWithName": "User {0} has been locked out", + "UserDownloadingItemWithValues": "{0} изтегля {1}", + "UserLockedOutWithName": "Потребител {0} се заключи", "UserOfflineFromDevice": "{0} се разкачи от {1}", "UserOnlineFromDevice": "{0} е на линия от {1}", "UserPasswordChangedWithName": "Паролата на потребителя {0} е променена", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", + "UserPolicyUpdatedWithName": "Потребителската политика за {0} се актуализира", "UserStartedPlayingItemWithValues": "{0} пусна {1}", "UserStoppedPlayingItemWithValues": "{0} спря {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "ValueHasBeenAddedToLibrary": "{0} беше добавен във Вашата библиотека", "ValueSpecialEpisodeName": "Специални - {0}", - "VersionNumber": "Версия {0}" + "VersionNumber": "Версия {0}", + "TaskDownloadMissingSubtitlesDescription": "Търси Интернет за липсващи субтитри, на база конфигурацията за мета-данни.", + "TaskDownloadMissingSubtitles": "Изтегляне на липсващи субтитри", + "TaskRefreshChannelsDescription": "Обновява информацията за интернет канала.", + "TaskRefreshChannels": "Обновяване на Канали", + "TaskCleanTranscodeDescription": "Изтрива транскодирани файлове по-стари от един ден.", + "TaskCleanTranscode": "Изчиства директорията за транскодиране", + "TaskUpdatePluginsDescription": "Изтегля и инсталира актуализации за добавките, които са настроени за автоматична актуализация.", + "TaskUpdatePlugins": "Актуализира добавките", + "TaskRefreshPeopleDescription": "Актуализира мета-данните за артистите и режисьорите за Вашата медийна библиотека.", + "TaskRefreshPeople": "Обновяване на участниците", + "TaskCleanLogsDescription": "Изтрива лог файлове по-стари от {0} дни.", + "TaskCleanLogs": "Изчисти директорията с логове", + "TaskRefreshLibraryDescription": "Сканира Вашата библиотека с медия за нови файлове и обновява мета-данните.", + "TaskRefreshLibrary": "Сканиране на библиотеката с медия", + "TaskRefreshChapterImagesDescription": "Създава иконки за видеа, които имат епизоди.", + "TaskRefreshChapterImages": "Извличане на изображения за епизода", + "TaskCleanCacheDescription": "Изтриване на ненужните от системата файлове.", + "TaskCleanCache": "Изчистване на Кеш-директорията", + "TasksChannelsCategory": "Интернет Канали", + "TasksApplicationCategory": "Приложение", + "TasksLibraryCategory": "Библиотека", + "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 new file mode 100644 index 000000000..c3fbe2408 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/bn.json @@ -0,0 +1,121 @@ +{ + "DeviceOnlineWithName": "{0}-এর সাথে সংযুক্ত হয়েছে", + "DeviceOfflineWithName": "{0}-এর সাথে সংযোগ বিচ্ছিন্ন হয়েছে", + "Collections": "সংগ্রহ", + "ChapterNameValue": "অধ্যায় {0}", + "Channels": "চ্যানেল", + "CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে", + "Books": "বই", + "AuthenticationSucceededWithUserName": "{0} অনুমোদন সফল", + "Artists": "শিল্পীরা", + "Application": "অ্যাপ্লিকেশন", + "Albums": "অ্যালবামগুলো", + "HeaderFavoriteEpisodes": "প্রিব পর্বগুলো", + "HeaderFavoriteArtists": "প্রিয় শিল্পীরা", + "HeaderFavoriteAlbums": "প্রিয় এলবামগুলো", + "HeaderContinueWatching": "দেখতে থাকুন", + "HeaderAlbumArtists": "এলবাম শিল্পীবৃন্দ", + "Genres": "শৈলী", + "Folders": "ফোল্ডারগুলো", + "Favorites": "পছন্দসমূহ", + "FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে", + "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {0}", + "VersionNumber": "সংস্করণ {0}", + "ValueSpecialEpisodeName": "বিশেষ - {0}", + "ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে", + "UserStoppedPlayingItemWithValues": "{2}তে {1} বাজানো শেষ করেছেন {0}", + "UserStartedPlayingItemWithValues": "{2}তে {1} বাজাচ্ছেন {0}", + "UserPolicyUpdatedWithName": "{0} এর জন্য ব্যবহার নীতি আপডেট করা হয়েছে", + "UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে", + "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন", + "UserOfflineFromDevice": "{0} {1} থেকে বিযুক্ত হয়ে গেছে", + "UserLockedOutWithName": "ব্যবহারকারী {0} ঢুকতে পারছে না", + "UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে", + "UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে", + "UserCreatedWithName": "ব্যবহারকারী {0} সৃষ্টি করা হয়েছে", + "User": "ব্যবহারকারী", + "TvShows": "টিভি শোগুলো", + "System": "সিস্টেম", + "Sync": "সিংক", + "SubtitleDownloadFailureFromForItem": "{2} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ", + "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": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে", + "HeaderRecordingGroups": "রেকর্ডিং দল", + "MessageNamedServerConfigurationUpdatedWithValue": "সার্ভারের {0} কনফিগারেসনের অংশ আপডেট করা হয়েছে", + "MessageApplicationUpdatedTo": "জেলিফিন সার্ভার {0} তে আপডেট করা হয়েছে", + "MessageApplicationUpdated": "জেলিফিন সার্ভার আপডেট করা হয়েছে", + "Latest": "সর্বশেষ", + "LabelRunningTimeValue": "চলার সময়: {0}", + "LabelIpAddressValue": "আইপি এড্রেস: {0}", + "ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে", + "ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে", + "Inherit": "থেকে পাওয়া", + "HomeVideos": "হোম ভিডিও", + "HeaderNextUp": "এরপরে আসছে", + "HeaderLiveTV": "লাইভ টিভি", + "HeaderFavoriteSongs": "প্রিয় গানগুলো", + "HeaderFavoriteShows": "প্রিয় শোগুলো", + "TasksLibraryCategory": "গ্রন্থাগার", + "TasksMaintenanceCategory": "রক্ষণাবেক্ষণ", + "TaskRefreshLibrary": "স্ক্যান মিডিয়া লাইব্রেরি", + "TaskRefreshChapterImagesDescription": "অধ্যায়গুলিতে থাকা ভিডিওগুলির জন্য থাম্বনেইল তৈরি ।", + "TaskRefreshChapterImages": "অধ্যায়ের চিত্রগুলি বের করুন", + "TaskCleanCacheDescription": "সিস্টেমে আর প্রয়োজন নেই ক্যাশ, ফাইলগুলি মুছে ফেলুন।", + "TaskCleanCache": "ক্লিন ক্যাশ ডিরেক্টরি", + "TasksChannelsCategory": "ইন্টারনেট চ্যানেল", + "TasksApplicationCategory": "আবেদন", + "TaskDownloadMissingSubtitlesDescription": "মেটাডেটা কনফিগারেশনের উপর ভিত্তি করে অনুপস্থিত সাবটাইটেলগুলির জন্য ইন্টারনেট অনুসন্ধান করে।", + "TaskDownloadMissingSubtitles": "অনুপস্থিত সাবটাইটেলগুলি ডাউনলোড করুন", + "TaskRefreshChannelsDescription": "ইন্টারনেট চ্যানেল তথ্য রিফ্রেশ করুন।", + "TaskRefreshChannels": "চ্যানেল রিফ্রেশ করুন", + "TaskCleanTranscodeDescription": "এক দিনেরও বেশি পুরানো ট্রান্সকোড ফাইলগুলি মুছে ফেলুন।", + "TaskCleanTranscode": "ট্রান্সকোড ডিরেক্টরি ক্লিন করুন", + "TaskUpdatePluginsDescription": "স্বয়ংক্রিয়ভাবে আপডেট কনফিগার করা প্লাগইনগুলির জন্য আপডেট ডাউনলোড এবং ইনস্টল করুন।", + "TaskUpdatePlugins": "প্লাগইন আপডেট করুন", + "TaskRefreshPeopleDescription": "আপনার মিডিয়া লাইব্রেরিতে অভিনেতা এবং পরিচালকদের জন্য মেটাডাটা আপডেট করুন।", + "TaskRefreshPeople": "পিপল রিফ্রেশ করুন", + "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলুন।", + "TaskCleanLogs": "লগ ডিরেক্টরি ক্লিন করুন", + "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 74406a064..db3c13d80 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -1,97 +1,124 @@ { "Albums": "Àlbums", - "AppDeviceValues": "App: {0}, Dispositiu: {1}", - "Application": "Application", + "AppDeviceValues": "Aplicació: {0}, Dispositiu: {1}", + "Application": "Aplicació", "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} s'ha autenticat correctament", "Books": "Llibres", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "S'ha pujat una nova imatge des de la camera desde {0}", "Channels": "Canals", - "ChapterNameValue": "Episodi {0}", + "ChapterNameValue": "Capítol {0}", "Collections": "Col·leccions", "DeviceOfflineWithName": "{0} s'ha desconnectat", "DeviceOnlineWithName": "{0} està connectat", "FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}", "Favorites": "Preferits", - "Folders": "Directoris", + "Folders": "Carpetes", "Genres": "Gèneres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", + "HeaderAlbumArtists": "Àlbum de l'artista", "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", "HomeVideos": "Vídeos domèstics", - "Inherit": "Heretat", - "ItemAddedWithName": "{0} afegit a la biblioteca", - "ItemRemovedWithName": "{0} eliminat de la biblioteca", + "Inherit": "Hereta", + "ItemAddedWithName": "{0} ha estat afegit a la biblioteca", + "ItemRemovedWithName": "{0} ha estat eliminat de la biblioteca", "LabelIpAddressValue": "Adreça IP: {0}", - "LabelRunningTimeValue": "Temps en marxa: {0}", + "LabelRunningTimeValue": "Temps en funcionament: {0}", "Latest": "Darreres", - "MessageApplicationUpdated": "El Servidor d'Jellyfin ha estat actualitzat", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "La secció de configuració {0} ha estat actualitzada", + "MessageApplicationUpdated": "El Servidor de Jellyfin ha estat actualitzat", + "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", - "NameInstallFailed": "{0} installation failed", + "NameInstallFailed": "Instalació de {0} fallida", "NameSeasonNumber": "Temporada {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NameSeasonUnknown": "Temporada Desconeguda", + "NewVersionIsAvailable": "Una nova versió del Servidor Jellyfin està disponible per descarregar.", "NotificationOptionApplicationUpdateAvailable": "Actualització d'aplicació disponible", "NotificationOptionApplicationUpdateInstalled": "Actualització d'aplicació instal·lada", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Un component ha fallat", - "NotificationOptionPluginInstalled": "Complement instal·lat", - "NotificationOptionPluginUninstalled": "Complement desinstal·lat", - "NotificationOptionPluginUpdateInstalled": "Actualització de complement instal·lada", - "NotificationOptionServerRestartRequired": "Server restart required", - "NotificationOptionTaskFailed": "Scheduled task failure", - "NotificationOptionUserLockedOut": "User locked out", - "NotificationOptionVideoPlayback": "Video playback started", - "NotificationOptionVideoPlaybackStopped": "Video playback stopped", + "NotificationOptionAudioPlayback": "Reproducció d'audio iniciada", + "NotificationOptionAudioPlaybackStopped": "Reproducció d'audio aturada", + "NotificationOptionCameraImageUploaded": "Imatge de càmera pujada", + "NotificationOptionInstallationFailed": "Instalació fallida", + "NotificationOptionNewLibraryContent": "Nou contingut afegit", + "NotificationOptionPluginError": "Un connector ha fallat", + "NotificationOptionPluginInstalled": "Connector instal·lat", + "NotificationOptionPluginUninstalled": "Connector desinstal·lat", + "NotificationOptionPluginUpdateInstalled": "Actualització de connector instal·lada", + "NotificationOptionServerRestartRequired": "Reinici del servidor requerit", + "NotificationOptionTaskFailed": "Tasca programada fallida", + "NotificationOptionUserLockedOut": "Usuari tancat", + "NotificationOptionVideoPlayback": "Reproducció de video iniciada", + "NotificationOptionVideoPlaybackStopped": "Reproducció de video aturada", "Photos": "Fotos", "Playlists": "Llistes de reproducció", - "Plugin": "Plugin", + "Plugin": "Connector", "PluginInstalledWithName": "{0} ha estat instal·lat", "PluginUninstalledWithName": "{0} ha estat desinstal·lat", "PluginUpdatedWithName": "{0} ha estat actualitzat", "ProviderValue": "Proveïdor: {0}", "ScheduledTaskFailedWithName": "{0} ha fallat", "ScheduledTaskStartedWithName": "{0} iniciat", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Espectacles", + "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciat", + "Shows": "Programes", "Songs": "Cançons", "StartupEmbyServerIsLoading": "El Servidor d'Jellyfin està carregant. Si et plau, prova de nou en breus.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtítols descarregats per a {0}", - "Sync": "Sync", - "System": "System", + "SubtitleDownloadFailureFromForItem": "Els subtítols no s'han pogut baixar de {0} per {1}", + "Sync": "Sincronitzar", + "System": "Sistema", "TvShows": "Espectacles de TV", "User": "User", "UserCreatedWithName": "S'ha creat l'usuari {0}", "UserDeletedWithName": "L'usuari {0} ha estat eliminat", "UserDownloadingItemWithValues": "{0} està descarregant {1}", - "UserLockedOutWithName": "User {0} has been locked out", + "UserLockedOutWithName": "L'usuari {0} ha sigut tancat", "UserOfflineFromDevice": "{0} s'ha desconnectat de {1}", "UserOnlineFromDevice": "{0} està connectat des de {1}", "UserPasswordChangedWithName": "La contrasenya ha estat canviada per a l'usuari {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", + "UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per {0}", "UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1}", "UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "ValueHasBeenAddedToLibrary": "{0} ha sigut afegit a la teva llibreria", "ValueSpecialEpisodeName": "Especial - {0}", - "VersionNumber": "Versió {0}" + "VersionNumber": "Versió {0}", + "TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.", + "TaskDownloadMissingSubtitles": "Descarrega els subtítols que faltin", + "TaskRefreshChannelsDescription": "Actualitza la informació dels canals d'internet.", + "TaskRefreshChannels": "Actualitza Canals", + "TaskCleanTranscodeDescription": "Elimina els arxius temporals de transcodificacions que tinguin més d'un dia.", + "TaskCleanTranscode": "Neteja les transcodificacions", + "TaskUpdatePluginsDescription": "Actualitza les extensions que estan configurades per actualitzar-se automàticament.", + "TaskUpdatePlugins": "Actualitza les extensions", + "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva mediateca.", + "TaskRefreshPeople": "Actualitza Persones", + "TaskCleanLogsDescription": "Esborra els logs que tinguin més de {0} dies.", + "TaskCleanLogs": "Neteja els registres", + "TaskRefreshLibraryDescription": "Escaneja la mediateca buscant fitxers nous i refresca les metadades.", + "TaskRefreshLibrary": "Escaneja la biblioteca de mitjans", + "TaskRefreshChapterImagesDescription": "Crea les miniatures dels vídeos que tinguin capítols.", + "TaskRefreshChapterImages": "Extreure les imatges dels capítols", + "TaskCleanCacheDescription": "Elimina els arxius temporals que ja no són necessaris per al servidor.", + "TaskCleanCache": "Elimina arxius temporals", + "TasksChannelsCategory": "Canals d'internet", + "TasksApplicationCategory": "Aplicació", + "TasksLibraryCategory": "Biblioteca", + "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 a8b4a4424..1ea378321 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -5,7 +5,7 @@ "Artists": "Umělci", "AuthenticationSucceededWithUserName": "{0} úspěšně ověřen", "Books": "Knihy", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "Z {0} byla nahrána nová fotografie z fotoaparátu", "Channels": "Kanály", "ChapterNameValue": "Kapitola {0}", "Collections": "Kolekce", @@ -15,18 +15,17 @@ "Favorites": "Oblíbené", "Folders": "Složky", "Genres": "Žánry", - "HeaderAlbumArtists": "Umělci alba", - "HeaderCameraUploads": "Camera Uploads", + "HeaderAlbumArtists": "Album umělce", "HeaderContinueWatching": "Pokračovat ve sledování", "HeaderFavoriteAlbums": "Oblíbená alba", - "HeaderFavoriteArtists": "Oblíbení umělci", + "HeaderFavoriteArtists": "Oblíbení interpreti", "HeaderFavoriteEpisodes": "Oblíbené epizody", "HeaderFavoriteShows": "Oblíbené seriály", - "HeaderFavoriteSongs": "Oblíbené písně", - "HeaderLiveTV": "Živá TV", + "HeaderFavoriteSongs": "Oblíbená hudba", + "HeaderLiveTV": "Televize", "HeaderNextUp": "Nadcházející", "HeaderRecordingGroups": "Skupiny nahrávek", - "HomeVideos": "Domáci videa", + "HomeVideos": "Domácí videa", "Inherit": "Zdědit", "ItemAddedWithName": "{0} byl přidán do knihovny", "ItemRemovedWithName": "{0} byl odstraněn z knihovny", @@ -34,17 +33,17 @@ "LabelRunningTimeValue": "Délka média: {0}", "Latest": "Nejnovější", "MessageApplicationUpdated": "Jellyfin Server byl aktualizován", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "MessageApplicationUpdatedTo": "Jellyfin server byl aktualizován na verzi {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurace sekce {0} na serveru byla aktualizována", "MessageServerConfigurationUpdated": "Konfigurace serveru aktualizována", "MixedContent": "Smíšený obsah", "Movies": "Filmy", "Music": "Hudba", - "MusicVideos": "Hudební klipy", - "NameInstallFailed": "{0} installation failed", + "MusicVideos": "Hudební videa", + "NameInstallFailed": "Instalace {0} selhala", "NameSeasonNumber": "Sezóna {0}", "NameSeasonUnknown": "Neznámá sezóna", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NewVersionIsAvailable": "Nová verze Jellyfin serveru je k dispozici ke stažení.", "NotificationOptionApplicationUpdateAvailable": "Dostupná aktualizace aplikace", "NotificationOptionApplicationUpdateInstalled": "Aktualizace aplikace instalována", "NotificationOptionAudioPlayback": "Přehrávání audia zahájeno", @@ -70,13 +69,12 @@ "ProviderValue": "Poskytl: {0}", "ScheduledTaskFailedWithName": "{0} selhalo", "ScheduledTaskStartedWithName": "{0} zahájeno", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", + "ServerNameNeedsToBeRestarted": "{0} vyžaduje restart", "Shows": "Seriály", "Songs": "Skladby", "StartupEmbyServerIsLoading": "Jellyfin Server je spouštěn. Zkuste to prosím v brzké době znovu.", "SubtitleDownloadFailureForItem": "Stahování titulků selhalo pro {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Staženy titulky pro {0}", + "SubtitleDownloadFailureFromForItem": "Stažení titulků pro {1} z {0} selhalo", "Sync": "Synchronizace", "System": "Systém", "TvShows": "TV seriály", @@ -88,10 +86,39 @@ "UserOfflineFromDevice": "{0} se odpojil od {1}", "UserOnlineFromDevice": "{0} se připojil z {1}", "UserPasswordChangedWithName": "Provedena změna hesla pro uživatele {0}", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", + "UserPolicyUpdatedWithName": "Zásady uživatele pro {0} byly aktualizovány", "UserStartedPlayingItemWithValues": "{0} spustil přehrávání {1}", "UserStoppedPlayingItemWithValues": "{0} zastavil přehrávání {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "ValueHasBeenAddedToLibrary": "{0} byl přidán do vaší knihovny médií", "ValueSpecialEpisodeName": "Speciál - {0}", - "VersionNumber": "Verze {0}" + "VersionNumber": "Verze {0}", + "TaskDownloadMissingSubtitlesDescription": "Vyhledá na internetu chybějící titulky na základě nastavení metadat.", + "TaskDownloadMissingSubtitles": "Stáhnout chybějící titulky", + "TaskRefreshChannelsDescription": "Obnoví informace o internetových kanálech.", + "TaskRefreshChannels": "Obnovit kanály", + "TaskCleanTranscodeDescription": "Odstraní více než 1 den staré transkódované soubory.", + "TaskCleanTranscode": "Vyčistit adresář s transkódovaným obsahem", + "TaskUpdatePluginsDescription": "Stáhne a nainstaluje aktualizace zásuvných modulů, které mají nastavenou automatickou aktualizaci.", + "TaskUpdatePlugins": "Aktualizovat zásuvné moduly", + "TaskRefreshPeopleDescription": "Aktualizuje metadata umělců a režisérů ve Vaší knihovně médií.", + "TaskRefreshPeople": "Obnovit umělce", + "TaskCleanLogsDescription": "Odstraní soubory protokolu, které jsou starší více než {0} dní.", + "TaskCleanLogs": "Vyčistit adresář se souborem protokolu", + "TaskRefreshLibraryDescription": "Prohledá Vaši knihovnu médií zda neobsahuje nové soubory a obnoví metadatada.", + "TaskRefreshLibrary": "Prohledat knihovnu médií", + "TaskRefreshChapterImagesDescription": "Vytvoří náhledy videí, které obsahují kapitoly.", + "TaskRefreshChapterImages": "Extrahovat obrázky kapitol", + "TaskCleanCacheDescription": "Odstraní soubory mezipaměti, které systém již nebude potřebovat.", + "TaskCleanCache": "Vyčistit složku s mezipamětí", + "TasksChannelsCategory": "Internetové kanály", + "TasksApplicationCategory": "Aplikace", + "TasksLibraryCategory": "Knihovna", + "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/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json new file mode 100644 index 000000000..0fa72dea4 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/cy.json @@ -0,0 +1,14 @@ +{ + "DeviceOnlineWithName": "Mae {0} wedi'i gysylltu", + "DeviceOfflineWithName": "Mae {0} wedi datgysylltu", + "Default": "Diofyn", + "Collections": "Casgliadau", + "ChapterNameValue": "Pennod {0}", + "Channels": "Sianeli", + "CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}", + "Books": "Llyfrau", + "AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus", + "Artists": "Artistiaid", + "AppDeviceValues": "Ap: {0}, Dyfais: {1}", + "Albums": "Albwmau" +} diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 9d4d74099..cfe365f57 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -1,9 +1,9 @@ { - "Albums": "Album", + "Albums": "Albummer", "AppDeviceValues": "App: {0}, Enhed: {1}", "Application": "Applikation", "Artists": "Kunstnere", - "AuthenticationSucceededWithUserName": "{0} bekræftet med succes", + "AuthenticationSucceededWithUserName": "{0} succesfuldt autentificeret", "Books": "Bøger", "CameraImageUploadedFrom": "Et nyt kamerabillede er blevet uploadet fra {0}", "Channels": "Kanaler", @@ -15,15 +15,14 @@ "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderAlbumArtists": "Albumkunstnere", - "HeaderCameraUploads": "Kamera Uploads", + "HeaderAlbumArtists": "Kunstnerens album", "HeaderContinueWatching": "Fortsæt Afspilning", - "HeaderFavoriteAlbums": "Favoritalbum", + "HeaderFavoriteAlbums": "Favoritalbummer", "HeaderFavoriteArtists": "Favoritkunstnere", - "HeaderFavoriteEpisodes": "Favorit-afsnit", - "HeaderFavoriteShows": "Favorit-serier", - "HeaderFavoriteSongs": "Favorit-sange", - "HeaderLiveTV": "Live TV", + "HeaderFavoriteEpisodes": "Favoritepisoder", + "HeaderFavoriteShows": "Favoritserier", + "HeaderFavoriteSongs": "Favoritsange", + "HeaderLiveTV": "Live-TV", "HeaderNextUp": "Næste", "HeaderRecordingGroups": "Optagelsesgrupper", "HomeVideos": "Hjemmevideoer", @@ -35,12 +34,12 @@ "Latest": "Seneste", "MessageApplicationUpdated": "Jellyfin Server er blevet opdateret", "MessageApplicationUpdatedTo": "Jellyfin Server er blevet opdateret til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfigurationsafsnit {0} er blevet opdateret", - "MessageServerConfigurationUpdated": "Serverkonfigurationen er blevet opdateret", + "MessageNamedServerConfigurationUpdatedWithValue": "Server konfiguration sektion {0} er blevet opdateret", + "MessageServerConfigurationUpdated": "Server konfigurationen er blevet opdateret", "MixedContent": "Blandet indhold", "Movies": "Film", "Music": "Musik", - "MusicVideos": "Musikvideoer", + "MusicVideos": "Musik videoer", "NameInstallFailed": "{0} installationen mislykkedes", "NameSeasonNumber": "Sæson {0}", "NameSeasonUnknown": "Ukendt Sæson", @@ -50,7 +49,7 @@ "NotificationOptionAudioPlayback": "Lydafspilning påbegyndt", "NotificationOptionAudioPlaybackStopped": "Lydafspilning stoppet", "NotificationOptionCameraImageUploaded": "Kamerabillede uploadet", - "NotificationOptionInstallationFailed": "Installationsfejl", + "NotificationOptionInstallationFailed": "Installationen fejlede", "NotificationOptionNewLibraryContent": "Nyt indhold tilføjet", "NotificationOptionPluginError": "Pluginfejl", "NotificationOptionPluginInstalled": "Plugin installeret", @@ -61,8 +60,8 @@ "NotificationOptionUserLockedOut": "Bruger låst ude", "NotificationOptionVideoPlayback": "Videoafspilning påbegyndt", "NotificationOptionVideoPlaybackStopped": "Videoafspilning stoppet", - "Photos": "Fotos", - "Playlists": "Spillelister", + "Photos": "Fotoer", + "Playlists": "Afspilningslister", "Plugin": "Plugin", "PluginInstalledWithName": "{0} blev installeret", "PluginUninstalledWithName": "{0} blev afinstalleret", @@ -76,7 +75,6 @@ "StartupEmbyServerIsLoading": "Jellyfin Server er i gang med at starte op. Prøv venligst igen om lidt.", "SubtitleDownloadFailureForItem": "Fejlet i download af undertekster for {0}", "SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke downloades fra {0} til {1}", - "SubtitlesDownloadedForItem": "Undertekster downloadet for {0}", "Sync": "Synk", "System": "System", "TvShows": "TV serier", @@ -93,5 +91,34 @@ "UserStoppedPlayingItemWithValues": "{0} har afsluttet afspilning af {1} på {2}", "ValueHasBeenAddedToLibrary": "{0} er blevet tilføjet til dit mediebibliotek", "ValueSpecialEpisodeName": "Special - {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata konfiguration.", + "TaskDownloadMissingSubtitles": "Download manglende undertekster", + "TaskUpdatePluginsDescription": "Downloader og installere opdateringer for plugins som er konfigureret til at opdatere automatisk.", + "TaskUpdatePlugins": "Opdater Plugins", + "TaskCleanLogsDescription": "Sletter log filer som er mere end {0} dage gammle.", + "TaskCleanLogs": "Ryd Log Mappe", + "TaskRefreshLibraryDescription": "Scanner dit medie bibliotek for nye filer og opdaterer metadata.", + "TaskRefreshLibrary": "Scan Medie Bibliotek", + "TaskCleanCacheDescription": "Sletter cache filer som systemet ikke har brug for længere.", + "TaskCleanCache": "Ryd Cache Mappe", + "TasksChannelsCategory": "Internet Kanaler", + "TasksApplicationCategory": "Applikation", + "TasksLibraryCategory": "Bibliotek", + "TasksMaintenanceCategory": "Vedligeholdelse", + "TaskRefreshChapterImages": "Udtræk Kapitel billeder", + "TaskRefreshChapterImagesDescription": "Lav miniaturebilleder for videoer der har kapitler.", + "TaskRefreshChannelsDescription": "Genopfrisker internet kanal information.", + "TaskRefreshChannels": "Genopfrisk Kanaler", + "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.", + "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 6fd63a514..c924e5c15 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -3,31 +3,30 @@ "AppDeviceValues": "App: {0}, Gerät: {1}", "Application": "Anwendung", "Artists": "Interpreten", - "AuthenticationSucceededWithUserName": "{0} hat sich angemeldet", + "AuthenticationSucceededWithUserName": "{0} erfolgreich authentifiziert", "Books": "Bücher", - "CameraImageUploadedFrom": "Ein neues Foto wurde hochgeladen von {0}", + "CameraImageUploadedFrom": "Ein neues Kamerafoto wurde von {0} hochgeladen", "Channels": "Kanäle", "ChapterNameValue": "Kapitel {0}", "Collections": "Sammlungen", - "DeviceOfflineWithName": "{0} wurde getrennt", - "DeviceOnlineWithName": "{0} hat sich verbunden", + "DeviceOfflineWithName": "{0} hat die Verbindung getrennt", + "DeviceOnlineWithName": "{0} ist verbunden", "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", "Favorites": "Favoriten", "Folders": "Verzeichnisse", "Genres": "Genres", "HeaderAlbumArtists": "Album-Interpreten", - "HeaderCameraUploads": "Kamera-Uploads", "HeaderContinueWatching": "Weiterschauen", "HeaderFavoriteAlbums": "Lieblingsalben", "HeaderFavoriteArtists": "Lieblings-Interpreten", "HeaderFavoriteEpisodes": "Lieblingsepisoden", "HeaderFavoriteShows": "Lieblingsserien", "HeaderFavoriteSongs": "Lieblingslieder", - "HeaderLiveTV": "Live-TV", + "HeaderLiveTV": "Live TV", "HeaderNextUp": "Als Nächstes", "HeaderRecordingGroups": "Aufnahme-Gruppen", "HomeVideos": "Heimvideos", - "Inherit": "Übernehmen", + "Inherit": "Vererben", "ItemAddedWithName": "{0} wurde der Bibliothek hinzugefügt", "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt", "LabelIpAddressValue": "IP-Adresse: {0}", @@ -35,7 +34,7 @@ "Latest": "Neueste", "MessageApplicationUpdated": "Jellyfin-Server wurde aktualisiert", "MessageApplicationUpdatedTo": "Jellyfin-Server wurde auf Version {0} aktualisiert", - "MessageNamedServerConfigurationUpdatedWithValue": "Der Server Einstellungsbereich {0} wurde aktualisiert", + "MessageNamedServerConfigurationUpdatedWithValue": "Der Server-Einstellungsbereich {0} wurde aktualisiert", "MessageServerConfigurationUpdated": "Servereinstellungen wurden aktualisiert", "MixedContent": "Gemischte Inhalte", "Movies": "Filme", @@ -50,7 +49,7 @@ "NotificationOptionAudioPlayback": "Audiowiedergabe gestartet", "NotificationOptionAudioPlaybackStopped": "Audiowiedergabe gestoppt", "NotificationOptionCameraImageUploaded": "Foto hochgeladen", - "NotificationOptionInstallationFailed": "Installationsfehler", + "NotificationOptionInstallationFailed": "Installation fehlgeschlagen", "NotificationOptionNewLibraryContent": "Neuer Inhalt hinzugefügt", "NotificationOptionPluginError": "Plugin-Fehler", "NotificationOptionPluginInstalled": "Plugin installiert", @@ -76,7 +75,6 @@ "StartupEmbyServerIsLoading": "Jellyfin-Server startet, bitte versuche es gleich noch einmal.", "SubtitleDownloadFailureForItem": "Download der Untertitel fehlgeschlagen für {0}", "SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden", - "SubtitlesDownloadedForItem": "Untertitel heruntergeladen für {0}", "Sync": "Synchronisation", "System": "System", "TvShows": "TV-Sendungen", @@ -93,5 +91,34 @@ "UserStoppedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} beendet", "ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt", "ValueSpecialEpisodeName": "Extra - {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TaskDownloadMissingSubtitlesDescription": "Durchsucht das Internet nach fehlenden Untertiteln, basierend auf den Meta Einstellungen.", + "TaskDownloadMissingSubtitles": "Lade fehlende Untertitel herunter", + "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": "Erstellt Vorschaubilder für Videos, welche Kapitel besitzen.", + "TaskRefreshChapterImages": "Extrahiert Kapitel-Bilder", + "TaskCleanCacheDescription": "Löscht nicht mehr benötigte Zwischenspeicherdateien.", + "TaskCleanCache": "Leere Zwischenspeicher", + "TasksChannelsCategory": "Internet Kanäle", + "TasksApplicationCategory": "Anwendung", + "TasksLibraryCategory": "Bibliothek", + "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 91ca34edc..697063f26 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -3,7 +3,7 @@ "AppDeviceValues": "Εφαρμογή: {0}, Συσκευή: {1}", "Application": "Εφαρμογή", "Artists": "Καλλιτέχνες", - "AuthenticationSucceededWithUserName": "{0} επιτυχείς σύνδεση", + "AuthenticationSucceededWithUserName": "Ο χρήστης {0} επαληθεύτηκε επιτυχώς", "Books": "Βιβλία", "CameraImageUploadedFrom": "Μια νέα εικόνα κάμερας έχει αποσταλεί από {0}", "Channels": "Κανάλια", @@ -15,9 +15,8 @@ "Favorites": "Αγαπημένα", "Folders": "Φάκελοι", "Genres": "Είδη", - "HeaderAlbumArtists": "Άλμπουμ Καλλιτεχνών", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Συνεχίστε να παρακολουθείτε", + "HeaderAlbumArtists": "Άλμπουμ Καλλιτέχνη", + "HeaderContinueWatching": "Συνεχίστε την παρακολούθηση", "HeaderFavoriteAlbums": "Αγαπημένα Άλμπουμ", "HeaderFavoriteArtists": "Αγαπημένοι Καλλιτέχνες", "HeaderFavoriteEpisodes": "Αγαπημένα Επεισόδια", @@ -27,20 +26,20 @@ "HeaderNextUp": "Επόμενο", "HeaderRecordingGroups": "Γκρουπ Εγγραφών", "HomeVideos": "Προσωπικά βίντεο", - "Inherit": "Inherit", + "Inherit": "Κληρονόμηση", "ItemAddedWithName": "{0} προστέθηκε στη βιβλιοθήκη", "ItemRemovedWithName": "{0} διαγράφηκε από τη βιβλιοθήκη", "LabelIpAddressValue": "Διεύθυνση IP: {0}", "LabelRunningTimeValue": "Διάρκεια: {0}", "Latest": "Πρόσφατα", "MessageApplicationUpdated": "Ο Jellyfin Server έχει ενημερωθεί", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "MessageApplicationUpdatedTo": "Ο server Jellyfin αναβαθμίστηκε σε έκδοση {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Η ενότητα {0} ρύθμισης παραμέτρων του server έχει ενημερωθεί", "MessageServerConfigurationUpdated": "Η ρύθμιση παραμέτρων του server έχει ενημερωθεί", "MixedContent": "Ανάμεικτο Περιεχόμενο", "Movies": "Ταινίες", "Music": "Μουσική", - "MusicVideos": "Μουσικά βίντεο", + "MusicVideos": "Μουσικά Βίντεο", "NameInstallFailed": "{0} η εγκατάσταση απέτυχε", "NameSeasonNumber": "Κύκλος {0}", "NameSeasonUnknown": "Άγνωστος Κύκλος", @@ -49,7 +48,7 @@ "NotificationOptionApplicationUpdateInstalled": "Η ενημέρωση εφαρμογής εγκαταστάθηκε", "NotificationOptionAudioPlayback": "Η αναπαραγωγή ήχου ξεκίνησε", "NotificationOptionAudioPlaybackStopped": "Η αναπαραγωγή ήχου σταμάτησε", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", + "NotificationOptionCameraImageUploaded": "Μεταφορτώθηκε φωτογραφία απο κάμερα", "NotificationOptionInstallationFailed": "Αποτυχία εγκατάστασης", "NotificationOptionNewLibraryContent": "Προστέθηκε νέο περιεχόμενο", "NotificationOptionPluginError": "Αποτυχία του plugin", @@ -63,7 +62,7 @@ "NotificationOptionVideoPlaybackStopped": "Η αναπαραγωγή βίντεο σταμάτησε", "Photos": "Φωτογραφίες", "Playlists": "Λίστες αναπαραγωγής", - "Plugin": "Plugin", + "Plugin": "Πρόσθετο", "PluginInstalledWithName": "{0} εγκαταστήθηκε", "PluginUninstalledWithName": "{0} έχει απεγκατασταθεί", "PluginUpdatedWithName": "{0} έχει αναβαθμιστεί", @@ -75,8 +74,7 @@ "Songs": "Τραγούδια", "StartupEmbyServerIsLoading": "Ο Jellyfin Server φορτώνει. Παρακαλώ δοκιμάστε σε λίγο.", "SubtitleDownloadFailureForItem": "Οι υπότιτλοι απέτυχαν να κατέβουν για {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Οι υπότιτλοι κατέβηκαν για {0}", + "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}", "Sync": "Συγχρονισμός", "System": "Σύστημα", "TvShows": "Τηλεοπτικές Σειρές", @@ -92,6 +90,35 @@ "UserStartedPlayingItemWithValues": "{0} παίζει {1} σε {2}", "UserStoppedPlayingItemWithValues": "{0} τελείωσε να παίζει {1} σε {2}", "ValueHasBeenAddedToLibrary": "{0} προστέθηκαν στη βιβλιοθήκη πολυμέσων σας", - "ValueSpecialEpisodeName": "Special - {0}", - "VersionNumber": "Έκδοση {0}" + "ValueSpecialEpisodeName": "Σπέσιαλ - {0}", + "VersionNumber": "Έκδοση {0}", + "TaskRefreshPeople": "Ανανέωση Ατόμων", + "TaskCleanLogsDescription": "Διαγράφει τα αρχεία καταγραφής που είναι άνω των {0} ημερών.", + "TaskCleanLogs": "Καθαρισμός Καταλόγου Καταγραφής", + "TaskRefreshLibraryDescription": "Σαρώνει την βιβλιοθήκη πολυμέσων σας για νέα αρχεία και αναζωογονεί τα μεταδεδομένα.", + "TaskRefreshLibrary": "Βιβλιοθήκη Σάρωσης Πολυμέσων", + "TaskRefreshChapterImagesDescription": "Δημιουργεί μικρογραφίες για βίντεο με κεφάλαια.", + "TaskRefreshChapterImages": "Εξαγωγή Εικόνων Κεφαλαίου", + "TaskCleanCacheDescription": "Τα διαγραμμένα αρχεία προσωρινής μνήμης που δεν χρειάζονται πλέον από το σύστημα.", + "TaskCleanCache": "Καθαρισμός Καταλόγου Προσωρινής Μνήμης", + "TasksChannelsCategory": "Κανάλια Διαδικτύου", + "TasksApplicationCategory": "Εφαρμογή", + "TasksLibraryCategory": "Βιβλιοθήκη", + "TasksMaintenanceCategory": "Συντήρηση", + "TaskDownloadMissingSubtitlesDescription": "Αναζητήσεις στο διαδίκτυο όπου λείπουν υπότιτλους με βάση τη διαμόρφωση μεταδεδομένων.", + "TaskDownloadMissingSubtitles": "Λήψη υπότιτλων που λείπουν", + "TaskRefreshChannelsDescription": "Ανανεώνει τις πληροφορίες καναλιού στο διαδικτύου.", + "TaskRefreshChannels": "Ανανέωση Καναλιών", + "TaskCleanTranscodeDescription": "Διαγράφει αρχείου διακωδικοποιητή περισσότερο από μία ημέρα.", + "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή", + "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.", + "TaskUpdatePlugins": "Ενημέρωση Προσθηκών", + "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 67d4068cf..86ce9240e 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -15,8 +15,7 @@ "Favorites": "Favourites", "Folders": "Folders", "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", + "HeaderAlbumArtists": "Artist's Album", "HeaderContinueWatching": "Continue Watching", "HeaderFavoriteAlbums": "Favourite Albums", "HeaderFavoriteArtists": "Favourite Artists", @@ -26,7 +25,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 +39,7 @@ "MixedContent": "Mixed content", "Movies": "Movies", "Music": "Music", - "MusicVideos": "Music videos", + "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", "NameSeasonNumber": "Season {0}", "NameSeasonUnknown": "Season Unknown", @@ -76,7 +75,6 @@ "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", "Sync": "Sync", "System": "System", "TvShows": "TV Shows", @@ -93,5 +91,34 @@ "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", "ValueSpecialEpisodeName": "Special - {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration.", + "TaskDownloadMissingSubtitles": "Download missing subtitles", + "TaskRefreshChannelsDescription": "Refreshes internet channel information.", + "TaskRefreshChannels": "Refresh Channels", + "TaskCleanTranscodeDescription": "Deletes transcode files more than one day old.", + "TaskCleanTranscode": "Clean Transcode Directory", + "TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.", + "TaskUpdatePlugins": "Update Plugins", + "TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.", + "TaskRefreshPeople": "Refresh People", + "TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.", + "TaskCleanLogs": "Clean Log Directory", + "TaskRefreshLibraryDescription": "Scans your media library for new files and refreshes metadata.", + "TaskRefreshLibrary": "Scan Media Library", + "TaskRefreshChapterImagesDescription": "Creates thumbnails for videos that have chapters.", + "TaskRefreshChapterImages": "Extract Chapter Images", + "TaskCleanCacheDescription": "Deletes cache files no longer needed by the system.", + "TaskCleanCache": "Clean Cache Directory", + "TasksChannelsCategory": "Internet Channels", + "TasksApplicationCategory": "Application", + "TasksLibraryCategory": "Library", + "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 aa855ed21..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", @@ -75,10 +76,10 @@ "Songs": "Songs", "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", "Sync": "Sync", "System": "System", "TvShows": "TV Shows", + "Undefined": "Undefined", "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", @@ -92,5 +93,31 @@ "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", "ValueSpecialEpisodeName": "Special - {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TasksMaintenanceCategory": "Maintenance", + "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", + "TaskRefreshChapterImagesDescription": "Creates thumbnails for videos that have chapters.", + "TaskRefreshLibrary": "Scan Media Library", + "TaskRefreshLibraryDescription": "Scans your media library for new files and refreshes metadata.", + "TaskCleanLogs": "Clean Log Directory", + "TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.", + "TaskRefreshPeople": "Refresh People", + "TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.", + "TaskUpdatePlugins": "Update Plugins", + "TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.", + "TaskCleanTranscode": "Clean Transcode Directory", + "TaskCleanTranscodeDescription": "Deletes transcode files more than one day old.", + "TaskRefreshChannels": "Refresh Channels", + "TaskRefreshChannelsDescription": "Refreshes internet channel information.", + "TaskDownloadMissingSubtitles": "Download missing subtitles", + "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..f92b5f673 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/eo.json @@ -0,0 +1,123 @@ +{ + "NotificationOptionInstallationFailed": "Instalada fiasko", + "NotificationOptionAudioPlaybackStopped": "Ludado de sono haltis", + "NotificationOptionAudioPlayback": "Ludado de sono lanĉis", + "NameSeasonUnknown": "Sezono Nekonata", + "NameSeasonNumber": "Sezono {0}", + "NameInstallFailed": "{0} instalado fiaskis", + "Music": "Muziko", + "Movies": "Filmoj", + "ItemRemovedWithName": "{0} forigis el la plurmediteko", + "ItemAddedWithName": "{0} aldonis al la plurmediteko", + "HeaderLiveTV": "TV-etero", + "HeaderContinueWatching": "Daŭrigi Spektadon", + "HeaderAlbumArtists": "Albumo de artisto", + "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": "Plurmediteko", + "VersionNumber": "Versio {0}", + "UserDownloadingItemWithValues": "{0} elŝutas {1}", + "UserCreatedWithName": "Uzanto {0} kreiĝis", + "User": "Uzanto", + "System": "Sistemo", + "Songs": "Kantoj", + "ScheduledTaskStartedWithName": "{0} lanĉis", + "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", + "HeaderFavoriteArtists": "Favorataj Artistoj", + "Shows": "Serioj", + "HeaderFavoriteShows": "Favorataj Serioj", + "TvShows": "TV-serioj", + "Favorites": "Favoratoj", + "TaskCleanLogs": "Purigi Ĵurnalan Katalogon", + "TaskRefreshLibrary": "Skanu Plurmeditekon", + "ValueSpecialEpisodeName": "Speciala - {0}", + "TaskOptimizeDatabase": "Optimigi datumbazon", + "TaskRefreshChannels": "Refreŝigi Kanalojn", + "TaskUpdatePlugins": "Ĝisdatigi Kromprogramojn", + "TaskRefreshPeople": "Refreŝigi Homojn", + "TasksChannelsCategory": "Interretaj Kanaloj", + "ProviderValue": "Provizanto: {0}", + "NotificationOptionPluginError": "Kromprograma malsukceso", + "MixedContent": "Miksita enhavo", + "TasksApplicationCategory": "Aplikaĵo", + "TasksMaintenanceCategory": "Prizorgado", + "Undefined": "Nedifinita", + "Sync": "Sinkronigo", + "Latest": "Plej novaj", + "Inherit": "Hereda", + "HomeVideos": "Hejmaj Videoj", + "HeaderNextUp": "Sekva Plue", + "HeaderFavoriteSongs": "Favorataj Kantoj", + "HeaderFavoriteEpisodes": "Favorataj Epizodoj", + "HeaderFavoriteAlbums": "Favorataj Albumoj", + "Forced": "Forcita", + "ServerNameNeedsToBeRestarted": "{0} devas esti relanĉita", + "NotificationOptionVideoPlayback": "La videoludado lanĉis", + "NotificationOptionServerRestartRequired": "Servila relanĉigo bezonata", + "TaskOptimizeDatabaseDescription": "Kompaktigas datenbazon kaj trunkas liberan lokon. Lanĉi ĉi tiun taskon post la teka skanado aŭ fari aliajn ŝanĝojn, kiuj implicas datenbazajn modifojn, povus plibonigi rendimenton.", + "TaskUpdatePluginsDescription": "Elŝutas kaj instalas ĝisdatigojn por kromprogramojn, kiuj estas agorditaj por ĝisdatigi aŭtomate.", + "TaskDownloadMissingSubtitlesDescription": "Serĉas en interreto mankantajn subtekstojn surbaze de metadatena agordaro.", + "TaskRefreshPeopleDescription": "Ĝisdatigas metadatenojn por aktoroj kaj reĵisoroj en via plurmediteko.", + "TaskCleanLogsDescription": "Forigas ĵurnalajn dosierojn aĝajn pli ol {0} tagojn.", + "TaskRefreshLibraryDescription": "Skanas vian plurmeditekon por novaj dosieroj kaj refreŝigas metadatenaron.", + "NewVersionIsAvailable": "Nova versio de Jellyfin Server estas elŝutebla.", + "TaskCleanCacheDescription": "Forigas stapla dosierojn ne plu necesajn de la sistemo.", + "TaskCleanActivityLogDescription": "Forigas aktivecan ĵurnalaĵojn pli malnovajn ol la agordita aĝo.", + "TaskCleanTranscodeDescription": "Forigas transkodajn dosierojn aĝajn pli ol unu tagon.", + "ValueHasBeenAddedToLibrary": "{0} estis aldonita al via plurmediteko", + "SubtitleDownloadFailureFromForItem": "Subtekstoj malsukcesis elŝuti de {0} por {1}", + "StartupEmbyServerIsLoading": "Jellyfin Server ŝarĝas. Provi denove baldaŭ.", + "TaskRefreshChapterImagesDescription": "Kreas bildetojn por videoj kiuj havas ĉapitrojn.", + "UserStoppedPlayingItemWithValues": "{0} finis ludi {1} ĉe {2}", + "UserPolicyUpdatedWithName": "Uzanta politiko estis ĝisdatigita por {0}", + "UserPasswordChangedWithName": "Pasvorto estis ŝanĝita por uzanto {0}", + "UserStartedPlayingItemWithValues": "{0} ludas {1} ĉe {2}", + "UserLockedOutWithName": "Uzanto {0} estas elŝlosita", + "UserOnlineFromDevice": "{0} estas enreta de {1}", + "UserOfflineFromDevice": "{0} malkonektis de {1}", + "UserDeletedWithName": "Uzanto {0} estis forigita", + "MessageServerConfigurationUpdated": "Servila agordaro estis ĝisdatigita", + "MessageNamedServerConfigurationUpdatedWithValue": "Servila agorda sekcio {0} estis ĝisdatigita", + "MessageApplicationUpdatedTo": "Jellyfin Server estis ĝisdatigita al {0}", + "MessageApplicationUpdated": "Jellyfin Server estis ĝisdatigita", + "TaskRefreshChannelsDescription": "Refreŝigas informon pri interretaj kanaloj.", + "TaskDownloadMissingSubtitles": "Elŝutu mankantajn subtekstojn", + "TaskCleanTranscode": "Malplenigi Transkodadan Katalogon", + "TaskRefreshChapterImages": "Eltiru Ĉapitro-Bildojn", + "TaskCleanCache": "Malplenigi Staplan Katalogon", + "TaskCleanActivityLog": "Malplenigi Aktivecan Ĵurnalon", + "PluginUpdatedWithName": "{0} estis ĝisdatigita", + "NotificationOptionVideoPlaybackStopped": "La videoludado haltis", + "NotificationOptionUserLockedOut": "Uzanto ŝlosita", + "NotificationOptionTaskFailed": "Planita tasko malsukcesis", + "NotificationOptionPluginUpdateInstalled": "Ĝisdatigo de kromprogramo instalita", + "NotificationOptionCameraImageUploaded": "Kamera bildo alŝutita", + "NotificationOptionApplicationUpdateInstalled": "Aplikaĵa ĝisdatigo instalita", + "NotificationOptionApplicationUpdateAvailable": "Ĝisdatigo de aplikaĵo havebla", + "LabelRunningTimeValue": "Ludada tempo: {0}", + "HeaderRecordingGroups": "Rikordadaj Grupoj", + "FailedLoginAttemptWithUserName": "Malsukcesa ensaluta provo de {0}", + "CameraImageUploadedFrom": "Nova kamera bildo estis alŝutita de {0}", + "AuthenticationSucceededWithUserName": "{0} sukcese aŭtentikigis" +} diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index c01bb0c50..6321f695c 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -1,97 +1,124 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Series", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "Albums": "Álbumes", + "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", + "Application": "Aplicación", + "Artists": "Artistas", + "AuthenticationSucceededWithUserName": "{0} autenticado correctamente", + "Books": "Libros", + "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", + "Channels": "Canales", + "ChapterNameValue": "Capítulo {0}", + "Collections": "Colecciones", + "DeviceOfflineWithName": "{0} se ha desconectado", + "DeviceOnlineWithName": "{0} está conectado", + "FailedLoginAttemptWithUserName": "Error al intentar iniciar sesión de {0}", + "Favorites": "Favoritos", + "Folders": "Carpetas", + "Genres": "Géneros", + "HeaderAlbumArtists": "Artistas de álbum", + "HeaderContinueWatching": "Seguir viendo", + "HeaderFavoriteAlbums": "Álbumes favoritos", + "HeaderFavoriteArtists": "Artistas favoritos", + "HeaderFavoriteEpisodes": "Capítulos favoritos", + "HeaderFavoriteShows": "Programas favoritos", + "HeaderFavoriteSongs": "Canciones favoritas", + "HeaderLiveTV": "TV en vivo", + "HeaderNextUp": "Siguiente", + "HeaderRecordingGroups": "Grupos de grabación", + "HomeVideos": "Videos caseros", + "Inherit": "Heredar", + "ItemAddedWithName": "{0} se ha añadido a la biblioteca", + "ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca", + "LabelIpAddressValue": "Dirección IP: {0}", + "LabelRunningTimeValue": "Tiempo de funcionamiento: {0}", + "Latest": "Últimos", + "MessageApplicationUpdated": "El servidor Jellyfin fue actualizado", + "MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", + "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", + "MixedContent": "Contenido mezclado", + "Movies": "Películas", + "Music": "Música", + "MusicVideos": "Videos musicales", + "NameInstallFailed": "{0} instalación fallida", + "NameSeasonNumber": "Temporada {0}", + "NameSeasonUnknown": "Temporada desconocida", + "NewVersionIsAvailable": "Una nueva versión del servidor Jellyfin está disponible para descargar.", + "NotificationOptionApplicationUpdateAvailable": "Actualización de la aplicación disponible", + "NotificationOptionApplicationUpdateInstalled": "Actualización de la aplicación instalada", + "NotificationOptionAudioPlayback": "Se inició la reproducción de audio", + "NotificationOptionAudioPlaybackStopped": "Se detuvo la reproducción de audio", + "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", + "NotificationOptionInstallationFailed": "Error de instalación", + "NotificationOptionNewLibraryContent": "Nuevo contenido añadido", + "NotificationOptionPluginError": "Falla de complemento", + "NotificationOptionPluginInstalled": "Complemento instalado", + "NotificationOptionPluginUninstalled": "Complemento desinstalado", + "NotificationOptionPluginUpdateInstalled": "Actualización de complemento instalada", + "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor", + "NotificationOptionTaskFailed": "Falla de tarea programada", + "NotificationOptionUserLockedOut": "Usuario bloqueado", + "NotificationOptionVideoPlayback": "Se inició la reproducción de video", + "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", + "Photos": "Fotos", + "Playlists": "Listas de reproducción", + "Plugin": "Complemento", + "PluginInstalledWithName": "{0} fue instalado", + "PluginUninstalledWithName": "{0} fue desinstalado", + "PluginUpdatedWithName": "{0} fue actualizado", + "ProviderValue": "Proveedor: {0}", + "ScheduledTaskFailedWithName": "{0} falló", + "ScheduledTaskStartedWithName": "{0} iniciado", + "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", + "Shows": "Programas", + "Songs": "Canciones", + "StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", - "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "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": "Special - {0}", - "VersionNumber": "Version {0}" + "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}", + "Sync": "Sincronizar", + "System": "Sistema", + "TvShows": "Series de TV", + "User": "Usuario", + "UserCreatedWithName": "El usuario {0} ha sido creado", + "UserDeletedWithName": "El usuario {0} ha sido borrado", + "UserDownloadingItemWithValues": "{0} está descargando {1}", + "UserLockedOutWithName": "El usuario {0} ha sido bloqueado", + "UserOfflineFromDevice": "{0} se ha desconectado de {1}", + "UserOnlineFromDevice": "{0} está en línea desde {1}", + "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", + "UserPolicyUpdatedWithName": "Las política de usuario ha sido actualizada para {0}", + "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", + "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", + "ValueHasBeenAddedToLibrary": "{0} ha sido añadido a tu biblioteca multimedia", + "ValueSpecialEpisodeName": "Especial - {0}", + "VersionNumber": "Versión {0}", + "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten basándose en la configuración de los metadatos.", + "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", + "TaskRefreshChannelsDescription": "Actualizar información de canales de internet.", + "TaskRefreshChannels": "Actualizar canales", + "TaskCleanTranscodeDescription": "Eliminar archivos transcodificados con mas de un día de antigüedad.", + "TaskCleanTranscode": "Limpiar directorio de transcodificación", + "TaskUpdatePluginsDescription": "Descargar e instalar actualizaciones para complementos que estén configurados en actualizar automáticamente.", + "TaskUpdatePlugins": "Actualizar complementos", + "TaskRefreshPeopleDescription": "Actualizar metadatos de actores y directores en su biblioteca multimedia.", + "TaskRefreshPeople": "Actualizar personas", + "TaskCleanLogsDescription": "Eliminar archivos de registro que tengan mas de {0} días de antigüedad.", + "TaskCleanLogs": "Limpiar directorio de registros", + "TaskRefreshLibraryDescription": "Escanear su biblioteca multimedia por nuevos archivos y refrescar metadatos.", + "TaskRefreshLibrary": "Escanear biblioteca multimedia", + "TaskRefreshChapterImagesDescription": "Crear miniaturas de videos que tengan capítulos.", + "TaskRefreshChapterImages": "Extraer imágenes de capítulo", + "TaskCleanCacheDescription": "Eliminar archivos de caché que no se necesiten en el sistema.", + "TaskCleanCache": "Limpiar directorio caché", + "TasksChannelsCategory": "Canales de internet", + "TasksApplicationCategory": "Aplicación", + "TasksLibraryCategory": "Biblioteca", + "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 2285f2808..432814dac 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -11,27 +11,26 @@ "Collections": "Colecciones", "DeviceOfflineWithName": "{0} se ha desconectado", "DeviceOnlineWithName": "{0} está conectado", - "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}", + "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", "HeaderAlbumArtists": "Artistas del Álbum", - "HeaderCameraUploads": "Subidos desde Camara", - "HeaderContinueWatching": "Continuar Viendo", - "HeaderFavoriteAlbums": "Álbumes Favoritos", - "HeaderFavoriteArtists": "Artistas Favoritos", - "HeaderFavoriteEpisodes": "Episodios Preferidos", - "HeaderFavoriteShows": "Programas Preferidos", - "HeaderFavoriteSongs": "Canciones Favoritas", - "HeaderLiveTV": "TV en Vivo", - "HeaderNextUp": "A Continuación", - "HeaderRecordingGroups": "Grupos de Grabaciones", - "HomeVideos": "Videos caseros", + "HeaderContinueWatching": "Continuar viendo", + "HeaderFavoriteAlbums": "Álbumes favoritos", + "HeaderFavoriteArtists": "Artistas favoritos", + "HeaderFavoriteEpisodes": "Episodios favoritos", + "HeaderFavoriteShows": "Programas favoritos", + "HeaderFavoriteSongs": "Canciones favoritas", + "HeaderLiveTV": "TV en vivo", + "HeaderNextUp": "A continuación", + "HeaderRecordingGroups": "Grupos de grabación", + "HomeVideos": "Videos Caseros", "Inherit": "Heredar", "ItemAddedWithName": "{0} fue agregado a la biblioteca", "ItemRemovedWithName": "{0} fue removido de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", - "LabelRunningTimeValue": "Duración: {0}", + "LabelRunningTimeValue": "Tiempo de reproducción: {0}", "Latest": "Recientes", "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", @@ -40,23 +39,23 @@ "MixedContent": "Contenido mezclado", "Movies": "Películas", "Music": "Música", - "MusicVideos": "Videos musicales", - "NameInstallFailed": "{0} instalación fallida", + "MusicVideos": "Videos Musicales", + "NameInstallFailed": "Falló la instalación de {0}", "NameSeasonNumber": "Temporada {0}", - "NameSeasonUnknown": "Temporada Desconocida", + "NameSeasonUnknown": "Temporada desconocida", "NewVersionIsAvailable": "Una nueva versión del Servidor Jellyfin está disponible para descargar.", - "NotificationOptionApplicationUpdateAvailable": "Actualización de aplicación disponible", - "NotificationOptionApplicationUpdateInstalled": "Actualización de aplicación instalada", + "NotificationOptionApplicationUpdateAvailable": "Actualización de la aplicación disponible", + "NotificationOptionApplicationUpdateInstalled": "Actualización de la aplicación instalada", "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", "NotificationOptionPluginUninstalled": "Complemento desinstalado", "NotificationOptionPluginUpdateInstalled": "Actualización de complemento instalada", - "NotificationOptionServerRestartRequired": "Se necesita reiniciar el Servidor", + "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor", "NotificationOptionTaskFailed": "Falla de tarea programada", "NotificationOptionUserLockedOut": "Usuario bloqueado", "NotificationOptionVideoPlayback": "Reproducción de video iniciada", @@ -69,29 +68,57 @@ "PluginUpdatedWithName": "{0} fue actualizado", "ProviderValue": "Proveedor: {0}", "ScheduledTaskFailedWithName": "{0} falló", - "ScheduledTaskStartedWithName": "{0} Iniciado", - "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", + "ScheduledTaskStartedWithName": "{0} iniciado", + "ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado", "Shows": "Programas", "Songs": "Canciones", - "StartupEmbyServerIsLoading": "El servidor Jellyfin esta cargando. Por favor intente de nuevo dentro de poco.", + "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", "SubtitleDownloadFailureForItem": "Falló la descarga de subtítulos para {0}", - "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}", - "SubtitlesDownloadedForItem": "Subtítulos descargados para {0}", + "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", "TvShows": "Programas de TV", "User": "Usuario", - "UserCreatedWithName": "Se ha creado el usuario {0}", - "UserDeletedWithName": "Se ha eliminado el usuario {0}", - "UserDownloadingItemWithValues": "{0} esta descargando {1}", + "UserCreatedWithName": "El usuario {0} ha sido creado", + "UserDeletedWithName": "El usuario {0} ha sido eliminado", + "UserDownloadingItemWithValues": "{0} está descargando {1}", "UserLockedOutWithName": "El usuario {0} ha sido bloqueado", "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", - "UserPolicyUpdatedWithName": "Las política de usuario ha sido actualizada por {0}", - "UserStartedPlayingItemWithValues": "{0} está reproduciéndose {1} en {2}", - "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducirse {1} en {2}", - "ValueHasBeenAddedToLibrary": "{0} se han añadido a su biblioteca de medios", + "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", + "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", + "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", + "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios", "ValueSpecialEpisodeName": "Especial - {0}", - "VersionNumber": "Versión {0}" + "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 los canales de Internet.", + "TaskRefreshChannels": "Actualizar canales", + "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", + "TaskRefreshPeopleDescription": "Actualiza metadatos de actores y directores en tu biblioteca de medios.", + "TaskRefreshPeople": "Actualizar personas", + "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad.", + "TaskCleanLogs": "Limpiar directorio de registros", + "TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios por archivos nuevos y actualiza los metadatos.", + "TaskRefreshLibrary": "Escanear biblioteca de medios", + "TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.", + "TaskRefreshChapterImages": "Extraer imágenes de los capítulos", + "TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.", + "TaskCleanCache": "Limpiar directorio caché", + "TasksChannelsCategory": "Canales de Internet", + "TasksApplicationCategory": "Aplicación", + "TasksLibraryCategory": "Biblioteca", + "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 1850b8f25..d3d9d2703 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -3,7 +3,7 @@ "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", "Application": "Aplicación", "Artists": "Artistas", - "AuthenticationSucceededWithUserName": "{0} autenticado correctamente", + "AuthenticationSucceededWithUserName": "{0} identificado correctamente", "Books": "Libros", "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", "Channels": "Canales", @@ -15,16 +15,15 @@ "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", - "HeaderAlbumArtists": "Artistas del álbum", - "HeaderCameraUploads": "Subidas desde cámara", + "HeaderAlbumArtists": "Artista del álbum", "HeaderContinueWatching": "Continuar viendo", "HeaderFavoriteAlbums": "Álbumes favoritos", "HeaderFavoriteArtists": "Artistas favoritos", "HeaderFavoriteEpisodes": "Episodios favoritos", - "HeaderFavoriteShows": "Programas favoritos", + "HeaderFavoriteShows": "Series favoritas", "HeaderFavoriteSongs": "Canciones favoritas", - "HeaderLiveTV": "TV en directo", - "HeaderNextUp": "Siguiendo", + "HeaderLiveTV": "Televisión en directo", + "HeaderNextUp": "Siguiente", "HeaderRecordingGroups": "Grupos de grabación", "HomeVideos": "Vídeos caseros", "Inherit": "Heredar", @@ -50,7 +49,7 @@ "NotificationOptionAudioPlayback": "Se inició la reproducción de audio", "NotificationOptionAudioPlaybackStopped": "Se detuvo la reproducción de audio", "NotificationOptionCameraImageUploaded": "Imagen de la cámara cargada", - "NotificationOptionInstallationFailed": "Error de instalación", + "NotificationOptionInstallationFailed": "Error en la instalación", "NotificationOptionNewLibraryContent": "Nuevo contenido añadido", "NotificationOptionPluginError": "Error en plugin", "NotificationOptionPluginInstalled": "Plugin instalado", @@ -76,16 +75,15 @@ "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}", - "SubtitlesDownloadedForItem": "Descargar subtítulos para {0}", "Sync": "Sincronizar", "System": "Sistema", - "TvShows": "Series de TV", + "TvShows": "Series", "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", "UserDownloadingItemWithValues": "{0} está descargando {1}", "UserLockedOutWithName": "El usuario {0} ha sido bloqueado", - "UserOfflineFromDevice": "{0} se ha desconectado de {1}", + "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", "UserOnlineFromDevice": "{0} está en línea desde {1}", "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", "UserPolicyUpdatedWithName": "Actualizada política de usuario para {0}", @@ -93,5 +91,34 @@ "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", "ValueHasBeenAddedToLibrary": "{0} ha sido añadido a tu biblioteca multimedia", "ValueSpecialEpisodeName": "Especial - {0}", - "VersionNumber": "Versión {0}" + "VersionNumber": "Versión {0}", + "TasksMaintenanceCategory": "Mantenimiento", + "TasksLibraryCategory": "Librería", + "TasksApplicationCategory": "Aplicación", + "TasksChannelsCategory": "Canales de internet", + "TaskCleanCache": "Eliminar archivos temporales", + "TaskCleanCacheDescription": "Elimina los archivos temporales que ya no son necesarios para el servidor.", + "TaskRefreshChapterImages": "Extraer imágenes de los capítulos", + "TaskRefreshChapterImagesDescription": "Crea las miniaturas de los vídeos que tengan capítulos.", + "TaskRefreshLibrary": "Escanear la biblioteca", + "TaskRefreshLibraryDescription": "Añade los archivos que se hayan añadido a la biblioteca y actualiza las etiquetas de los ya presentes.", + "TaskCleanLogs": "Limpiar registros", + "TaskCleanLogsDescription": "Elimina los archivos de registro que tengan más de {0} días.", + "TaskRefreshPeople": "Actualizar personas", + "TaskRefreshPeopleDescription": "Actualiza las etiquetas de los intérpretes y directores presentes en tus bibliotecas.", + "TaskUpdatePlugins": "Actualizar extensiones", + "TaskUpdatePluginsDescription": "Actualiza las extensiones que están configuradas para actualizarse automáticamente.", + "TaskCleanTranscode": "Limpiar las transcodificaciones", + "TaskCleanTranscodeDescription": "Elimina los archivos temporales de transcodificación anteriores a un día de antigüedad.", + "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.", + "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 new file mode 100644 index 000000000..a968c6dab --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -0,0 +1,123 @@ +{ + "LabelRunningTimeValue": "Tiempo en ejecución: {0}", + "ValueSpecialEpisodeName": "Especial - {0}", + "Sync": "Sincronizar", + "Songs": "Canciones", + "Shows": "Programas", + "Playlists": "Listas de reproducción", + "Photos": "Fotos", + "Movies": "Películas", + "HeaderNextUp": "A continuación", + "HeaderLiveTV": "TV en vivo", + "HeaderFavoriteSongs": "Canciones favoritas", + "HeaderFavoriteArtists": "Artistas favoritos", + "HeaderFavoriteAlbums": "Álbumes favoritos", + "HeaderFavoriteEpisodes": "Episodios favoritos", + "HeaderFavoriteShows": "Programas favoritos", + "HeaderContinueWatching": "Continuar viendo", + "HeaderAlbumArtists": "Artistas del álbum", + "Genres": "Géneros", + "Folders": "Carpetas", + "Favorites": "Favoritos", + "Collections": "Colecciones", + "Channels": "Canales", + "Books": "Libros", + "Artists": "Artistas", + "Albums": "Álbumes", + "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.", + "TaskRefreshChannels": "Actualizar canales", + "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.", + "TaskCleanTranscode": "Limpiar directorio de transcodificado", + "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para complementos que están configurados para actualizarse automáticamente.", + "TaskUpdatePlugins": "Actualizar complementos", + "TaskRefreshPeopleDescription": "Actualiza metadatos de actores y directores en tu biblioteca de medios.", + "TaskRefreshPeople": "Actualizar personas", + "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad.", + "TaskCleanLogs": "Limpiar directorio de registros", + "TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios por archivos nuevos y actualiza los metadatos.", + "TaskRefreshLibrary": "Escanear biblioteca de medios", + "TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.", + "TaskRefreshChapterImages": "Extraer imágenes de los capítulos", + "TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.", + "TaskCleanCache": "Limpiar directorio caché", + "TasksChannelsCategory": "Canales de Internet", + "TasksApplicationCategory": "Aplicación", + "TasksLibraryCategory": "Biblioteca", + "TasksMaintenanceCategory": "Mantenimiento", + "VersionNumber": "Versión {0}", + "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca de medios", + "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", + "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", + "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", + "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", + "UserOnlineFromDevice": "{0} está en línea desde {1}", + "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", + "UserLockedOutWithName": "El usuario {0} ha sido bloqueado", + "UserDownloadingItemWithValues": "{0} está descargando {1}", + "UserDeletedWithName": "El usuario {0} ha sido eliminado", + "UserCreatedWithName": "El usuario {0} ha sido creado", + "User": "Usuario", + "TvShows": "Programas de TV", + "System": "Sistema", + "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", + "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.", + "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", + "ScheduledTaskStartedWithName": "{0} iniciado", + "ScheduledTaskFailedWithName": "{0} falló", + "ProviderValue": "Proveedor: {0}", + "PluginUpdatedWithName": "{0} fue actualizado", + "PluginUninstalledWithName": "{0} fue desinstalado", + "PluginInstalledWithName": "{0} fue instalado", + "Plugin": "Complemento", + "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", + "NotificationOptionVideoPlayback": "Reproducción de video iniciada", + "NotificationOptionUserLockedOut": "Usuario bloqueado", + "NotificationOptionTaskFailed": "Falla de tarea programada", + "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor", + "NotificationOptionPluginUpdateInstalled": "Actualización de complemento instalada", + "NotificationOptionPluginUninstalled": "Complemento desinstalado", + "NotificationOptionPluginInstalled": "Complemento instalado", + "NotificationOptionPluginError": "Falla de complemento", + "NotificationOptionNewLibraryContent": "Nuevo contenido agregado", + "NotificationOptionInstallationFailed": "Falla de instalación", + "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", + "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida", + "NotificationOptionAudioPlayback": "Reproducción de audio iniciada", + "NotificationOptionApplicationUpdateInstalled": "Actualización de la aplicación instalada", + "NotificationOptionApplicationUpdateAvailable": "Actualización de la aplicación disponible", + "NewVersionIsAvailable": "Una nueva versión del Servidor Jellyfin está disponible para descargar.", + "NameSeasonUnknown": "Temporada desconocida", + "NameSeasonNumber": "Temporada {0}", + "NameInstallFailed": "Falló la instalación de {0}", + "MusicVideos": "Videos musicales", + "Music": "Música", + "MixedContent": "Contenido mezclado", + "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", + "MessageNamedServerConfigurationUpdatedWithValue": "Se ha actualizado la sección {0} de la configuración del servidor", + "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", + "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", + "Latest": "Recientes", + "LabelIpAddressValue": "Dirección IP: {0}", + "ItemRemovedWithName": "{0} fue removido de la biblioteca", + "ItemAddedWithName": "{0} fue agregado a la biblioteca", + "Inherit": "Heredar", + "HomeVideos": "Videos caseros", + "HeaderRecordingGroups": "Grupos de grabación", + "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}", + "DeviceOnlineWithName": "{0} está conectado", + "DeviceOfflineWithName": "{0} se ha desconectado", + "ChapterNameValue": "Capítulo {0}", + "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}", + "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 new file mode 100644 index 000000000..b64ffbfbb --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/es_DO.json @@ -0,0 +1,23 @@ +{ + "Channels": "Canales", + "Books": "Libros", + "Albums": "Álbumes", + "Collections": "Colecciones", + "Artists": "Artistas", + "DeviceOnlineWithName": "{0} está conectado", + "DeviceOfflineWithName": "{0} se ha desconectado", + "ChapterNameValue": "Capítulo {0}", + "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", + "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", + "Application": "Aplicación", + "AppDeviceValues": "App: {0}, Dispositivo: {1}", + "HeaderContinueWatching": "Continuar Viendo", + "HeaderAlbumArtists": "Artistas del Álbum", + "Genres": "Géneros", + "Folders": "Carpetas", + "Favorites": "Favoritos", + "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/et.json b/Emby.Server.Implementations/Localization/Core/et.json new file mode 100644 index 000000000..c3596ecf1 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -0,0 +1,118 @@ +{ + "TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.", + "UserDownloadingItemWithValues": "{0} laeb alla {1}", + "HeaderRecordingGroups": "Salvestusrühmad", + "TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.", + "TaskOptimizeDatabase": "Optimeeri andmebaasi", + "TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.", + "TaskDownloadMissingSubtitles": "Laadi alla puuduvad subtiitrid", + "TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.", + "TaskRefreshChannels": "Värskenda kanaleid", + "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkodeerimisfailid.", + "TaskCleanTranscode": "Puhasta transkoodimise kataloog", + "TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.", + "TaskUpdatePlugins": "Uuenda pluginaid", + "TaskRefreshPeopleDescription": "Värskendab meediakogus näitlejate ja režissööride metaandmeid.", + "TaskRefreshPeople": "Värskenda inimesi", + "TaskCleanLogsDescription": "Kustutab logifailid, mis on vanemad kui {0} päeva.", + "TaskCleanLogs": "Puhasta logikataloog", + "TaskRefreshLibraryDescription": "Otsib meedikogust uusi faile ja värskendab metaandmeid.", + "Collections": "Kollektsioonid", + "TaskRefreshLibrary": "Skaneeri meediakogu", + "TaskRefreshChapterImagesDescription": "Loob peatükkidega videote jaoks pisipildid.", + "TaskRefreshChapterImages": "Eralda peatükipildid", + "TaskCleanCacheDescription": "Kustutab vahemälufailid, mida süsteem enam ei vaja.", + "TaskCleanCache": "Puhasta vahemälu kataloog", + "TaskCleanActivityLog": "Puhasta tegevuslogi", + "TasksChannelsCategory": "Veebikanalid", + "TasksApplicationCategory": "Rakendus", + "TasksLibraryCategory": "Meediakogu", + "TasksMaintenanceCategory": "Hooldus", + "VersionNumber": "Versioon {0}", + "ValueSpecialEpisodeName": "Eriepisood - {0}", + "ValueHasBeenAddedToLibrary": "{0} lisati meediakogusse", + "UserStartedPlayingItemWithValues": "{0} taasesitab {1} serveris {2}", + "UserPasswordChangedWithName": "Kasutaja {0} parooli on muudetud", + "UserLockedOutWithName": "Kasutaja {0} on lukustatud", + "UserDeletedWithName": "Kasutaja {0} on kustutatud", + "UserCreatedWithName": "Kasutaja {0} on loodud", + "ScheduledTaskStartedWithName": "{0} käivitati", + "ProviderValue": "Allikas: {0}", + "StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.", + "User": "Kasutaja", + "Undefined": "Määratlemata", + "TvShows": "Seriaalid", + "System": "Süsteem", + "Sync": "Sünkrooni", + "Songs": "Laulud", + "Shows": "Sarjad", + "ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada", + "ScheduledTaskFailedWithName": "{0} nurjus", + "PluginUpdatedWithName": "{0} uuendati", + "PluginUninstalledWithName": "{0} eemaldati", + "PluginInstalledWithName": "{0} paigaldati", + "Plugin": "Plugin", + "Playlists": "Pleilistid", + "Photos": "Fotod", + "NotificationOptionVideoPlaybackStopped": "Video taasesitus on peatatud", + "NotificationOptionVideoPlayback": "Video taasesitus algas", + "NotificationOptionUserLockedOut": "Kasutaja on lukustatud", + "NotificationOptionTaskFailed": "Ajastatud ülesanne nurjus", + "NotificationOptionServerRestartRequired": "Vajalik on serveri taaskäivitamine", + "NotificationOptionPluginUpdateInstalled": "Paigaldati plugina uuendus", + "NotificationOptionPluginUninstalled": "Plugin eemaldati", + "NotificationOptionPluginInstalled": "Plugin paigaldati", + "NotificationOptionPluginError": "Plugina tõrge", + "NotificationOptionNewLibraryContent": "Lisati uut sisu", + "NotificationOptionInstallationFailed": "Paigaldamine nurjus", + "NotificationOptionCameraImageUploaded": "Kaamera pilt on üles laaditud", + "NotificationOptionAudioPlaybackStopped": "Heli taasesitus peatati", + "NotificationOptionAudioPlayback": "Heli taasesitus algas", + "NotificationOptionApplicationUpdateInstalled": "Rakenduse uuendus paigaldati", + "NotificationOptionApplicationUpdateAvailable": "Rakenduse uuendus on saadaval", + "NewVersionIsAvailable": "Jellyfin serveri uus versioon on allalaadimiseks saadaval.", + "NameSeasonUnknown": "Tundmatu hooaeg", + "NameSeasonNumber": "Hooaeg {0}", + "NameInstallFailed": "{0} paigaldamine nurjus", + "MusicVideos": "Muusikavideod", + "Music": "Muusika", + "Movies": "Filmid", + "MixedContent": "Segatud sisu", + "MessageServerConfigurationUpdated": "Serveri seadistust uuendati", + "MessageNamedServerConfigurationUpdatedWithValue": "Serveri seadistusosa {0} uuendati", + "MessageApplicationUpdatedTo": "Jellyfin server uuendati versioonile {0}", + "MessageApplicationUpdated": "Jellyfin server uuendati", + "Latest": "Uusimad", + "LabelRunningTimeValue": "Kestus: {0}", + "LabelIpAddressValue": "IP aadress: {0}", + "ItemRemovedWithName": "{0} eemaldati meediakogust", + "ItemAddedWithName": "{0} lisati meediakogusse", + "Inherit": "Päri", + "HomeVideos": "Koduvideod", + "HeaderNextUp": "Järgmisena", + "HeaderLiveTV": "Otse TV", + "HeaderFavoriteSongs": "Lemmiklood", + "HeaderFavoriteShows": "Lemmikseriaalid", + "HeaderFavoriteEpisodes": "Lemmikepisoodid", + "HeaderFavoriteArtists": "Lemmikesitajad", + "HeaderFavoriteAlbums": "Lemmikalbumid", + "HeaderContinueWatching": "Jätka vaatamist", + "HeaderAlbumArtists": "Albumi esitaja", + "Genres": "Žanrid", + "Forced": "Sunnitud", + "Folders": "Kaustad", + "Favorites": "Lemmikud", + "FailedLoginAttemptWithUserName": "Ebaõnnestunud sisselogimiskatse kasutajalt {0}", + "DeviceOnlineWithName": "{0} on ühendatud", + "DeviceOfflineWithName": "{0} katkestas ühenduse", + "Default": "Vaikimisi", + "ChapterNameValue": "Peatükk {0}", + "Channels": "Kanalid", + "CameraImageUploadedFrom": "Uus kaamera pilt laaditi üles allikalt {0}", + "Books": "Raamatud", + "AuthenticationSucceededWithUserName": "{0} autentimine õnnestus", + "Artists": "Esitajad", + "Application": "Rakendus", + "AppDeviceValues": "Rakendus: {0}, seade: {1}", + "Albums": "Albumid" +} diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index 0a0c7553b..8ab657e5b 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -1,56 +1,55 @@ { - "Albums": "آلبوم ها", + "Albums": "آلبومها", "AppDeviceValues": "برنامه: {0} ، دستگاه: {1}", "Application": "برنامه", "Artists": "هنرمندان", "AuthenticationSucceededWithUserName": "{0} با موفقیت تایید اعتبار شد", - "Books": "کتاب ها", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "کانال ها", - "ChapterNameValue": "فصل {0}", - "Collections": "کلکسیون ها", + "Books": "کتابها", + "CameraImageUploadedFrom": "یک عکس جدید از دوربین ارسال شده است {0}", + "Channels": "کانالها", + "ChapterNameValue": "قسمت {0}", + "Collections": "مجموعهها", "DeviceOfflineWithName": "ارتباط {0} قطع شد", - "DeviceOnlineWithName": "{0} متصل شده", + "DeviceOnlineWithName": "{0} متصل شد", "FailedLoginAttemptWithUserName": "تلاش برای ورود از {0} ناموفق بود", - "Favorites": "مورد علاقه ها", - "Folders": "پوشه ها", + "Favorites": "مورد علاقهها", + "Folders": "پوشهها", "Genres": "ژانرها", "HeaderAlbumArtists": "هنرمندان آلبوم", - "HeaderCameraUploads": "آپلودهای دوربین", "HeaderContinueWatching": "ادامه تماشا", - "HeaderFavoriteAlbums": "آلبوم های مورد علاقه", + "HeaderFavoriteAlbums": "آلبومهای مورد علاقه", "HeaderFavoriteArtists": "هنرمندان مورد علاقه", - "HeaderFavoriteEpisodes": "قسمت های مورد علاقه", - "HeaderFavoriteShows": "سریال های مورد علاقه", - "HeaderFavoriteSongs": "آهنگ های مورد علاقه", - "HeaderLiveTV": "پخش زنده تلویزیون", - "HeaderNextUp": "بعدی چیه", - "HeaderRecordingGroups": "گروه های ضبط", + "HeaderFavoriteEpisodes": "قسمتهای مورد علاقه", + "HeaderFavoriteShows": "سریالهای مورد علاقه", + "HeaderFavoriteSongs": "آهنگهای مورد علاقه", + "HeaderLiveTV": "پخش زنده", + "HeaderNextUp": "قسمت بعدی", + "HeaderRecordingGroups": "گروههای ضبط", "HomeVideos": "ویدیوهای خانگی", "Inherit": "به ارث برده", "ItemAddedWithName": "{0} به کتابخانه افزوده شد", "ItemRemovedWithName": "{0} از کتابخانه حذف شد", "LabelIpAddressValue": "آدرس آی پی: {0}", "LabelRunningTimeValue": "زمان اجرا: {0}", - "Latest": "آخرین", + "Latest": "جدیدترینها", "MessageApplicationUpdated": "سرور Jellyfin بروزرسانی شد", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", + "MessageApplicationUpdatedTo": "سرور Jellyfin به نسخه {0} بروزرسانی شد", + "MessageNamedServerConfigurationUpdatedWithValue": "پکربندی بخش {0} سرور بروزرسانی شد", "MessageServerConfigurationUpdated": "پیکربندی سرور بروزرسانی شد", - "MixedContent": "محتوای درهم", - "Movies": "فیلم های سینمایی", + "MixedContent": "محتوای مخلوط", + "Movies": "فیلمها", "Music": "موسیقی", "MusicVideos": "موزیک ویدیوها", - "NameInstallFailed": "{0} installation failed", + "NameInstallFailed": "{0} نصب با مشکل مواجه شد", "NameSeasonNumber": "فصل {0}", - "NameSeasonUnknown": "فصل های ناشناخته", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NameSeasonUnknown": "فصل ناشناخته", + "NewVersionIsAvailable": "یک نسخه جدید Jellyfin برای بروزرسانی آماده میباشد.", "NotificationOptionApplicationUpdateAvailable": "بروزرسانی برنامه موجود است", "NotificationOptionApplicationUpdateInstalled": "بروزرسانی برنامه نصب شد", "NotificationOptionAudioPlayback": "پخش صدا آغاز شد", "NotificationOptionAudioPlaybackStopped": "پخش صدا متوقف شد", "NotificationOptionCameraImageUploaded": "تصاویر دوربین آپلود شد", - "NotificationOptionInstallationFailed": "شکست نصب", + "NotificationOptionInstallationFailed": "نصب ناموفق", "NotificationOptionNewLibraryContent": "محتوای جدید افزوده شد", "NotificationOptionPluginError": "خرابی افزونه", "NotificationOptionPluginInstalled": "افزونه نصب شد", @@ -58,40 +57,66 @@ "NotificationOptionPluginUpdateInstalled": "بروزرسانی افزونه نصب شد", "NotificationOptionServerRestartRequired": "شروع مجدد سرور نیاز است", "NotificationOptionTaskFailed": "شکست وظیفه برنامه ریزی شده", - "NotificationOptionUserLockedOut": "کاربر از سیستم خارج شد", + "NotificationOptionUserLockedOut": "کاربر قفل شد", "NotificationOptionVideoPlayback": "پخش ویدیو آغاز شد", "NotificationOptionVideoPlaybackStopped": "پخش ویدیو متوقف شد", - "Photos": "عکس ها", - "Playlists": "لیست های پخش", + "Photos": "عکسها", + "Playlists": "لیستهای پخش", "Plugin": "افزونه", "PluginInstalledWithName": "{0} نصب شد", "PluginUninstalledWithName": "{0} حذف شد", "PluginUpdatedWithName": "{0} آپدیت شد", "ProviderValue": "ارائه دهنده: {0}", - "ScheduledTaskFailedWithName": "{0} ناموفق بود", + "ScheduledTaskFailedWithName": "{0} شکست خورد", "ScheduledTaskStartedWithName": "{0} شروع شد", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "سریال ها", - "Songs": "آهنگ ها", + "ServerNameNeedsToBeRestarted": "{0} نیاز به راه اندازی مجدد دارد", + "Shows": "سریالها", + "Songs": "موسیقیها", "StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.", "SubtitleDownloadFailureForItem": "دانلود زیرنویس برای {0} ناموفق بود", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "زیرنویس {0} دانلود شد", - "Sync": "همگامسازی", + "SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد", + "Sync": "همگامسازی", "System": "سیستم", - "TvShows": "سریال های تلویزیونی", + "TvShows": "سریالهای تلویزیونی", "User": "کاربر", "UserCreatedWithName": "کاربر {0} ایجاد شد", "UserDeletedWithName": "کاربر {0} حذف شد", - "UserDownloadingItemWithValues": "{0} در حال دانلود است {1}", - "UserLockedOutWithName": "کاربر {0} از سیستم خارج شد", + "UserDownloadingItemWithValues": "{0} در حال بارگیری {1} میباشد", + "UserLockedOutWithName": "کاربر {0} قفل شده است", "UserOfflineFromDevice": "ارتباط {0} از {1} قطع شد", - "UserOnlineFromDevice": "{0}از {1} آنلاین میباشد", - "UserPasswordChangedWithName": "رمز برای کاربر {0} تغییر یافت", + "UserOnlineFromDevice": "{0} از {1} آنلاین میباشد", + "UserPasswordChangedWithName": "گذرواژه برای کاربر {0} تغییر کرد", "UserPolicyUpdatedWithName": "سیاست کاربری برای {0} بروزرسانی شد", - "UserStartedPlayingItemWithValues": "{0} شروع به پخش {1} کرد", - "UserStoppedPlayingItemWithValues": "{0} پخش {1} را متوقف کرد", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", - "ValueSpecialEpisodeName": "ویژه- {0}", - "VersionNumber": "نسخه {0}" + "UserStartedPlayingItemWithValues": "{0} در حال پخش {1} بر روی {2} است", + "UserStoppedPlayingItemWithValues": "{0} پخش {1} را بر روی {2} به پایان رساند", + "ValueHasBeenAddedToLibrary": "{0} به کتابخانهی رسانهی شما افزوده شد", + "ValueSpecialEpisodeName": "ویژه - {0}", + "VersionNumber": "نسخه {0}", + "TaskCleanTranscodeDescription": "فایلهای کدگذاری که قدیمیتر از یک روز هستند را حذف میکند.", + "TaskCleanTranscode": "پاکسازی مسیر کد گذاری", + "TaskUpdatePluginsDescription": "دانلود و نصب به روز رسانی افزونههایی که برای به روز رسانی خودکار پیکربندی شدهاند.", + "TaskDownloadMissingSubtitlesDescription": "جستجوی زیرنویسهای ناموجود در اینترنت بر اساس پیکربندی ابردادهها.", + "TaskDownloadMissingSubtitles": "دانلود زیرنویسهای ناموجود", + "TaskRefreshChannelsDescription": "اطلاعات کانال اینترنتی را تازه سازی میکند.", + "TaskRefreshChannels": "تازه سازی کانالها", + "TaskUpdatePlugins": "به روز رسانی افزونهها", + "TaskRefreshPeopleDescription": "ابردادهها برای بازیگران و کارگردانان در کتابخانه رسانه شما به روزرسانی می شوند.", + "TaskRefreshPeople": "تازه سازی افراد", + "TaskCleanLogsDescription": "واقعه نگارهایی را که قدیمی تر {0} روز هستند را حذف می کند.", + "TaskCleanLogs": "پاکسازی مسیر واقعه نگار", + "TaskRefreshLibraryDescription": "کتابخانه رسانه شما را اسکن میکند و ابردادهها را تازه سازی میکند.", + "TaskRefreshLibrary": "اسکن کتابخانه رسانه", + "TaskRefreshChapterImagesDescription": "عکسهای کوچک برای ویدیوهایی که سکانس دارند ایجاد میکند.", + "TaskRefreshChapterImages": "استخراج عکسهای سکانس", + "TaskCleanCacheDescription": "فایلهای حافظه موقت که توسط سیستم دیگر مورد نیاز نیستند حذف میشوند.", + "TaskCleanCache": "پاکسازی مسیر حافظه موقت", + "TasksChannelsCategory": "کانالهای داخلی", + "TasksApplicationCategory": "برنامه", + "TasksLibraryCategory": "کتابخانه", + "TasksMaintenanceCategory": "تعمیر", + "Forced": "اجباری", + "Default": "پیشفرض", + "TaskCleanActivityLogDescription": "ورودیهای قدیمیتر از سن تنظیم شده در سیاهه فعالیت را حذف میکند.", + "TaskCleanActivityLog": "پاکسازی سیاهه فعالیت", + "Undefined": "تعریف نشده" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json new file mode 100644 index 000000000..4a1f4f1d5 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -0,0 +1,123 @@ +{ + "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": "Sekalainen sisältö", + "MessageServerConfigurationUpdated": "Palvelimen asetukset on päivitetty", + "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": "Peri", + "HomeVideos": "Kotivideot", + "HeaderRecordingGroups": "Tallennusryhmät", + "HeaderNextUp": "Seuraavaksi", + "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": "Epäonnistunut kirjautumisyritys lähteestä \"{0}\"", + "DeviceOnlineWithName": "{0} on yhdistetty", + "DeviceOfflineWithName": "{0} on katkaissut yhteyden", + "Collections": "Kokoelmat", + "ChapterNameValue": "Kappale {0}", + "Channels": "Kanavat", + "CameraImageUploadedFrom": "Uusi kameran kuva on sirretty lähteestä {0}", + "Books": "Kirjat", + "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äivitettiin", + "PluginInstalledWithName": "{0} asennettiin", + "Photos": "Valokuvat", + "ScheduledTaskStartedWithName": "\"{0}\" käynnistetty", + "PluginUninstalledWithName": "{0} poistettiin", + "Playlists": "Soittolistat", + "VersionNumber": "Versio {0}", + "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}\" 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 on tallennettu", + "NotificationOptionAudioPlaybackStopped": "Äänen toisto lopetettu", + "NotificationOptionAudioPlayback": "Äänen toisto aloitettu", + "NotificationOptionApplicationUpdateInstalled": "Sovelluspäivitys asennettiin", + "NotificationOptionApplicationUpdateAvailable": "Sovelluspäivitys on saatavilla", + "TasksMaintenanceCategory": "Ylläpito", + "TaskDownloadMissingSubtitlesDescription": "Etsii puuttuvia tekstityksiä määritettyjen metatietoasetusten mukaisesti.", + "TaskDownloadMissingSubtitles": "Lataa puuttuvat tekstitykset", + "TaskRefreshChannelsDescription": "Päivittää internet-kanavien tiedot.", + "TaskRefreshChannels": "Päivitä kanavat", + "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 {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", + "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 new file mode 100644 index 000000000..f18a1c030 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -0,0 +1,121 @@ +{ + "VersionNumber": "Bersyon {0}", + "ValueSpecialEpisodeName": "Espesyal - {0}", + "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong librerya ng medya", + "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}", + "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 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": "Mga Palabas sa Telebisyon", + "System": "Sistema", + "Sync": "Pag-sync", + "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 ang {0}", + "ProviderValue": "Tagapagtustos: {0}", + "PluginUpdatedWithName": "Naiupdate na ang {0}", + "PluginUninstalledWithName": "Naiuninstall na ang {0}", + "PluginInstalledWithName": "Nainstall na ang {0}", + "Plugin": "Plugin", + "Playlists": "Mga Playlist", + "Photos": "Mga Larawan", + "NotificationOptionVideoPlaybackStopped": "Huminto na ang pelikula", + "NotificationOptionVideoPlayback": "Nagsimula na ang pelikula", + "NotificationOptionUserLockedOut": "Naka-lock out ang user", + "NotificationOptionTaskFailed": "Hindi gumana ang scheduled task", + "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 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 i-download.", + "NameSeasonUnknown": "Hindi matukoy ang season", + "NameSeasonNumber": "Season {0}", + "NameInstallFailed": "Hindi nainstall ang {0}", + "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 bersyon ng Jellyfin Server ay naiupdate sa {0}", + "MessageApplicationUpdated": "Naiupdate na ang Jellyfin Server", + "Latest": "Pinakabago", + "LabelRunningTimeValue": "Oras: {0}", + "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": "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": "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": "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 sistema.", + "TasksChannelsCategory": "Palabas sa internet", + "TasksLibraryCategory": "Librerya", + "TasksMaintenanceCategory": "Pagpapanatili", + "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 7202be9f5..2a56d0745 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -1,97 +1,124 @@ { "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", + "AppDeviceValues": "App : {0}, Appareil : {1}", "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", + "Artists": "Artistes", + "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", + "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", + "DeviceOfflineWithName": "{0} s'est déconnecté", + "DeviceOnlineWithName": "{0} est connecté", + "FailedLoginAttemptWithUserName": "Tentative de connexion échoué par {0}", + "Favorites": "Favoris", + "Folders": "Dossiers", "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continuer à regarder", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", + "HeaderAlbumArtists": "Artistes de l'album", + "HeaderContinueWatching": "Reprendre le visionnement", + "HeaderFavoriteAlbums": "Albums favoris", + "HeaderFavoriteArtists": "Artistes favoris", + "HeaderFavoriteEpisodes": "Épisodes favoris", + "HeaderFavoriteShows": "Séries favorites", + "HeaderFavoriteSongs": "Chansons favorites", + "HeaderLiveTV": "TV en direct", "HeaderNextUp": "À Suivre", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", + "HeaderRecordingGroups": "Groupes d'enregistrements", + "HomeVideos": "Vidéos personnelles", + "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": "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", + "MessageServerConfigurationUpdated": "La configuration du serveur a été mise à jour", + "MixedContent": "Contenu mixte", + "Movies": "Films", + "Music": "Musique", + "MusicVideos": "Vidéos musicales", + "NameInstallFailed": "échec d'installation de {0}", + "NameSeasonNumber": "Saison {0}", + "NameSeasonUnknown": "Saison Inconnue", + "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": "Image d'appareil photo transférée", + "NotificationOptionInstallationFailed": "Échec d'installation", + "NotificationOptionNewLibraryContent": "Nouveau contenu ajouté", + "NotificationOptionPluginError": "Erreur d'extension", + "NotificationOptionPluginInstalled": "Extension installée", + "NotificationOptionPluginUninstalled": "Extension désinstallée", + "NotificationOptionPluginUpdateInstalled": "Mise à jour d'extension installée", + "NotificationOptionServerRestartRequired": "Un redémarrage du serveur est requis", + "NotificationOptionTaskFailed": "Échec de tâche planifiée", + "NotificationOptionUserLockedOut": "Utilisateur verrouillé", + "NotificationOptionVideoPlayback": "Lecture vidéo démarrée", + "NotificationOptionVideoPlaybackStopped": "Lecture vidéo arrêtée", "Photos": "Photos", - "Playlists": "Playlists", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Series", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "Playlists": "Listes de lecture", + "Plugin": "Extension", + "PluginInstalledWithName": "{0} a été installé", + "PluginUninstalledWithName": "{0} a été désinstallé", + "PluginUpdatedWithName": "{0} a été mis à jour", + "ProviderValue": "Fournisseur : {0}", + "ScheduledTaskFailedWithName": "{0} a échoué", + "ScheduledTaskStartedWithName": "{0} a commencé", + "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", + "Shows": "Séries", + "Songs": "Chansons", + "StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", - "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", - "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", + "Sync": "Synchroniser", + "System": "Système", + "TvShows": "Séries Télé", + "User": "Utilisateur", + "UserCreatedWithName": "L'utilisateur {0} a été créé", + "UserDeletedWithName": "L'utilisateur {0} supprimé", + "UserDownloadingItemWithValues": "{0} télécharge {1}", + "UserLockedOutWithName": "L'utilisateur {0} a été verrouillé", + "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} 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}" + "VersionNumber": "Version {0}", + "TasksLibraryCategory": "Médiathèque", + "TasksMaintenanceCategory": "Entretien", + "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 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é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 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 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 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": "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é", + "TaskOptimizeDatabaseDescription": "Compacte la base de données et tronque l'espace libre. Lancer cette tâche après avoir scanné la bibliothèque ou faire d'autres changements impliquant des modifications de la base peuvent ameliorer les performances.", + "TaskOptimizeDatabase": "Optimiser la base de données" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 52afb4e49..1265b6ef5 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -1,30 +1,29 @@ { "Albums": "Albums", "AppDeviceValues": "Application : {0}, Appareil : {1}", - "Application": "Application", + "Application": "Applications", "Artists": "Artistes", - "AuthenticationSucceededWithUserName": "{0} s'est authentifié avec succès", + "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", - "CameraImageUploadedFrom": "Une image de caméra a été chargée depuis {0}", + "CameraImageUploadedFrom": "Une photo a été chargée depuis {0}", "Channels": "Chaînes", "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", "DeviceOfflineWithName": "{0} s'est déconnecté", "DeviceOnlineWithName": "{0} est connecté", - "FailedLoginAttemptWithUserName": "Échec d'une tentative de connexion de {0}", + "FailedLoginAttemptWithUserName": "Échec de connexion depuis {0}", "Favorites": "Favoris", "Folders": "Dossiers", "Genres": "Genres", "HeaderAlbumArtists": "Artistes de l'album", - "HeaderCameraUploads": "Photos transférées", "HeaderContinueWatching": "Continuer à regarder", "HeaderFavoriteAlbums": "Albums favoris", - "HeaderFavoriteArtists": "Artistes favoris", + "HeaderFavoriteArtists": "Artistes préférés", "HeaderFavoriteEpisodes": "Épisodes favoris", "HeaderFavoriteShows": "Séries favorites", - "HeaderFavoriteSongs": "Chansons favorites", + "HeaderFavoriteSongs": "Chansons préférées", "HeaderLiveTV": "TV en direct", - "HeaderNextUp": "En Cours", + "HeaderNextUp": "À suivre", "HeaderRecordingGroups": "Groupes d'enregistrements", "HomeVideos": "Vidéos personnelles", "Inherit": "Hériter", @@ -34,23 +33,23 @@ "LabelRunningTimeValue": "Durée : {0}", "Latest": "Derniers", "MessageApplicationUpdated": "Le serveur Jellyfin a été mis à jour", - "MessageApplicationUpdatedTo": "Jellyfin Serveur a été mis à jour en version {0}", + "MessageApplicationUpdatedTo": "Le serveur Jellyfin a été mis à jour en version {0}", "MessageNamedServerConfigurationUpdatedWithValue": "La configuration de la section {0} du serveur a été mise à jour", "MessageServerConfigurationUpdated": "La configuration du serveur a été mise à jour", "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", - "MusicVideos": "Vidéos musicales", - "NameInstallFailed": "{0} échec d'installation", + "MusicVideos": "Clips musicaux", + "NameInstallFailed": "{0} échec de l'installation", "NameSeasonNumber": "Saison {0}", "NameSeasonUnknown": "Saison Inconnue", - "NewVersionIsAvailable": "Une nouvelle version d'Jellyfin Serveur est disponible au téléchargement.", + "NewVersionIsAvailable": "Une nouvelle version de Jellyfin Serveur est disponible au téléchargement.", "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", - "NotificationOptionInstallationFailed": "Échec d'installation", + "NotificationOptionInstallationFailed": "Échec de l'installation", "NotificationOptionNewLibraryContent": "Nouveau contenu ajouté", "NotificationOptionPluginError": "Erreur d'extension", "NotificationOptionPluginInstalled": "Extension installée", @@ -69,14 +68,13 @@ "PluginUpdatedWithName": "{0} a été mis à jour", "ProviderValue": "Fournisseur : {0}", "ScheduledTaskFailedWithName": "{0} a échoué", - "ScheduledTaskStartedWithName": "{0} a commencé", + "ScheduledTaskStartedWithName": "{0} a démarré", "ServerNameNeedsToBeRestarted": "{0} doit être redémarré", "Shows": "Émissions", "Songs": "Chansons", "StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.", "SubtitleDownloadFailureForItem": "Le téléchargement des sous-titres pour {0} a échoué.", "SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}", - "SubtitlesDownloadedForItem": "Les sous-titres de {0} ont été téléchargés", "Sync": "Synchroniser", "System": "Système", "TvShows": "Séries Télé", @@ -89,9 +87,38 @@ "UserOnlineFromDevice": "{0} s'est connecté depuis {1}", "UserPasswordChangedWithName": "Le mot de passe pour l'utilisateur {0} a été modifié", "UserPolicyUpdatedWithName": "La politique de l'utilisateur a été mise à jour pour {0}", - "UserStartedPlayingItemWithValues": "{0} est entrain de lire {1} sur {2}", + "UserStartedPlayingItemWithValues": "{0} est en train de lire {1} sur {2}", "UserStoppedPlayingItemWithValues": "{0} vient d'arrêter la lecture de {1} sur {2}", - "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre librairie", + "ValueHasBeenAddedToLibrary": "{0} a été ajouté à votre médiathèque", "ValueSpecialEpisodeName": "Spécial - {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TasksChannelsCategory": "Chaines en ligne", + "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é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 votre médiathèque pour trouver les nouveaux fichiers et rafraîchit les métadonnées.", + "TaskRefreshLibrary": "Scanner la médiathèque", + "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", + "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 new file mode 100644 index 000000000..b433c6f68 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -0,0 +1,123 @@ +{ + "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 do 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}", + "TaskOptimizeDatabaseDescription": "Compacta e libera o espazo libre da base de datos. Executar esta tarefa logo de realizar mudanzas que impliquen modificacións da base de datos ou despois de escanear a biblioteca pode traer mellorías de desempeño.", + "TaskOptimizeDatabase": "Optimizar base de datos" +} diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index 728002a56..3364ee333 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -1,97 +1,122 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Büecher", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", + "Albums": "Alben", + "AppDeviceValues": "App: {0}, Gerät: {1}", + "Application": "Anwendung", + "Artists": "Künstler", + "AuthenticationSucceededWithUserName": "{0} hat sich angemeldet", + "Books": "Bücher", + "CameraImageUploadedFrom": "Ein neues Foto wurde von {0} hochgeladen", + "Channels": "Kanäle", + "ChapterNameValue": "Kapitel {0}", + "Collections": "Sammlungen", + "DeviceOfflineWithName": "{0} wurde getrennt", + "DeviceOnlineWithName": "{0} ist verbunden", + "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", + "Favorites": "Favoriten", + "Folders": "Ordner", "Genres": "Genres", - "HeaderAlbumArtists": "Albuminterprete", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Wiiterluege", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Besti Interpret", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Besti Lieder", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Ufnahmegruppe", - "HomeVideos": "Heimfilmli", - "Inherit": "Hinzuefüege", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Letschte", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Gmischte Inhalt", - "Movies": "Movies", + "HeaderAlbumArtists": "Album-Künstler", + "HeaderContinueWatching": "weiter schauen", + "HeaderFavoriteAlbums": "Lieblingsalben", + "HeaderFavoriteArtists": "Lieblings-Künstler", + "HeaderFavoriteEpisodes": "Lieblingsepisoden", + "HeaderFavoriteShows": "Lieblingsserien", + "HeaderFavoriteSongs": "Lieblingslieder", + "HeaderLiveTV": "Live-Fernseh", + "HeaderNextUp": "Als Nächstes", + "HeaderRecordingGroups": "Aufnahme-Gruppen", + "HomeVideos": "Heimvideos", + "Inherit": "Vererben", + "ItemAddedWithName": "{0} wurde der Bibliothek hinzugefügt", + "ItemRemovedWithName": "{0} wurde aus der Bibliothek entfernt", + "LabelIpAddressValue": "IP-Adresse: {0}", + "LabelRunningTimeValue": "Laufzeit: {0}", + "Latest": "Neueste", + "MessageApplicationUpdated": "Jellyfin-Server wurde aktualisiert", + "MessageApplicationUpdatedTo": "Jellyfin-Server wurde auf Version {0} aktualisiert", + "MessageNamedServerConfigurationUpdatedWithValue": "Der Server-Einstellungsbereich {0} wurde aktualisiert", + "MessageServerConfigurationUpdated": "Serveriistöuige send aktualisiert worde", + "MixedContent": "Gmeschti Inhäut", + "Movies": "Film", "Music": "Musig", - "MusicVideos": "Musigfilm", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", + "MusicVideos": "Musigvideos", + "NameInstallFailed": "Installation vo {0} fäugschlage", + "NameSeasonNumber": "Staffle {0}", + "NameSeasonUnknown": "Staffle unbekannt", + "NewVersionIsAvailable": "E nöi Version vo Jellyfin Server esch zom Download parat.", + "NotificationOptionApplicationUpdateAvailable": "Aawändigsupdate verfüegbar", + "NotificationOptionApplicationUpdateInstalled": "Aawändigsupdate installiert", + "NotificationOptionAudioPlayback": "Audiowedergab gstartet", + "NotificationOptionAudioPlaybackStopped": "Audiwedergab gstoppt", + "NotificationOptionCameraImageUploaded": "Foti ueglade", + "NotificationOptionInstallationFailed": "Installationsfehler", + "NotificationOptionNewLibraryContent": "Nöie Inhaut hinzuegfüegt", + "NotificationOptionPluginError": "Plugin-Fäuer", + "NotificationOptionPluginInstalled": "Plugin installiert", + "NotificationOptionPluginUninstalled": "Plugin deinstalliert", + "NotificationOptionPluginUpdateInstalled": "Pluginupdate installiert", + "NotificationOptionServerRestartRequired": "Serverneustart notwändig", + "NotificationOptionTaskFailed": "Planti Uufgab fäugschlage", + "NotificationOptionUserLockedOut": "Benotzer usgschlosse", + "NotificationOptionVideoPlayback": "Videowedergab gstartet", + "NotificationOptionVideoPlaybackStopped": "Videowedergab gstoppt", "Photos": "Fotis", - "Playlists": "Abspielliste", + "Playlists": "Wedergabeliste", "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "PluginInstalledWithName": "{0} esch installiert worde", + "PluginUninstalledWithName": "{0} esch deinstalliert worde", + "PluginUpdatedWithName": "{0} esch updated worde", + "ProviderValue": "Aabieter: {0}", + "ScheduledTaskFailedWithName": "{0} esch fäugschlage", + "ScheduledTaskStartedWithName": "{0} het gstartet", + "ServerNameNeedsToBeRestarted": "{0} mues nöi gstartet wärde", + "Shows": "Serie", + "Songs": "Lieder", + "StartupEmbyServerIsLoading": "Jellyfin Server ladt. Bitte grad noeinisch probiere.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", + "SubtitleDownloadFailureFromForItem": "Ondertetle vo {0} för {1} hend ned chönne abeglade wärde", + "Sync": "Synchronisation", "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "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": "Spezial - {0}", - "VersionNumber": "Version {0}" + "TvShows": "Färnsehserie", + "User": "Benotzer", + "UserCreatedWithName": "Benotzer {0} esch erstöut worde", + "UserDeletedWithName": "Benotzer {0} esch glösche worde", + "UserDownloadingItemWithValues": "{0} ladt {1} abe", + "UserLockedOutWithName": "Benotzer {0} esch usgschlosse worde", + "UserOfflineFromDevice": "{0} esch vo {1} trennt worde", + "UserOnlineFromDevice": "{0} esch online vo {1}", + "UserPasswordChangedWithName": "S'Passwort för Benotzer {0} esch gänderet worde", + "UserPolicyUpdatedWithName": "Benotzerrechtlinie för {0} esch aktualisiert worde", + "UserStartedPlayingItemWithValues": "{0} hed d'Wedergab vo {1} of {2} gstartet", + "UserStoppedPlayingItemWithValues": "{0} het d'Wedergab vo {1} of {2} gstoppt", + "ValueHasBeenAddedToLibrary": "{0} esch dinnere Biblithek hinzuegfüegt worde", + "ValueSpecialEpisodeName": "Extra - {0}", + "VersionNumber": "Version {0}", + "TaskCleanLogs": "Lösche Log Pfad", + "TaskRefreshLibraryDescription": "Scanne alle Bibliotheken für hinzugefügte Datein und erneuere Metadaten.", + "TaskRefreshLibrary": "Scanne alle Bibliotheken", + "TaskRefreshChapterImagesDescription": "Kreiert Vorschaubilder für Videos welche Kapitel haben.", + "TaskRefreshChapterImages": "Extrahiere Kapitel-Bilder", + "TaskCleanCacheDescription": "Löscht Zwischenspeicherdatein die nicht länger von System gebraucht werden.", + "TaskCleanCache": "Leere Cache Pfad", + "TasksChannelsCategory": "Internet Kanäle", + "TasksApplicationCategory": "Applikation", + "TasksLibraryCategory": "Bibliothek", + "TasksMaintenanceCategory": "Verwaltung", + "TaskDownloadMissingSubtitlesDescription": "Durchsucht das Internet nach fehlenden Untertiteln, basierend auf den Metadaten Einstellungen.", + "TaskDownloadMissingSubtitles": "Lade fehlende Untertitel herunter", + "TaskRefreshChannelsDescription": "Aktualisiert Internet Kanal Informationen.", + "TaskRefreshChannels": "Aktualisiere Kanäle", + "TaskCleanTranscodeDescription": "Löscht Transkodierdateien welche älter als ein Tag sind.", + "TaskCleanTranscode": "Räume Transcodier Verzeichnis auf", + "TaskUpdatePluginsDescription": "Lädt Aktualisierungen für Erweiterungen herunter und installiert diese, für welche automatische Aktualisierungen konfiguriert sind.", + "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.", + "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 fff1d1f0e..981e8a06e 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -1,97 +1,122 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", + "Albums": "אלבומים", + "AppDeviceValues": "יישום: {0}, מכשיר: {1}", + "Application": "יישום", + "Artists": "אומנים", + "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", "Books": "ספרים", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", + "CameraImageUploadedFrom": "תמונת מצלמה חדשה הועלתה מ {0}", + "Channels": "ערוצים", + "ChapterNameValue": "פרק {0}", + "Collections": "אוספים", + "DeviceOfflineWithName": "{0} התנתק", + "DeviceOnlineWithName": "{0} מחובר", + "FailedLoginAttemptWithUserName": "ניסיון כניסה שגוי מ{0}", + "Favorites": "מועדפים", + "Folders": "תיקיות", "Genres": "ז'אנרים", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "המשך בצפייה", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", + "HeaderAlbumArtists": "אמני האלבום", + "HeaderContinueWatching": "המשך לצפות", + "HeaderFavoriteAlbums": "אלבומים מועדפים", + "HeaderFavoriteArtists": "אמנים מועדפים", + "HeaderFavoriteEpisodes": "פרקים מועדפים", + "HeaderFavoriteShows": "תוכניות מועדפות", + "HeaderFavoriteSongs": "שירים מועדפים", + "HeaderLiveTV": "שידורים חיים", + "HeaderNextUp": "הבא בתור", "HeaderRecordingGroups": "קבוצות הקלטה", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", + "HomeVideos": "סרטונים בייתים", + "Inherit": "הורש", + "ItemAddedWithName": "{0} הוסף לספרייה", + "ItemRemovedWithName": "{0} נמחק מהספרייה", + "LabelIpAddressValue": "Ip כתובת: {0}", + "LabelRunningTimeValue": "משך צפייה: {0}", "Latest": "אחרון", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", + "MessageApplicationUpdated": "שרת הJellyfin עודכן", + "MessageApplicationUpdatedTo": "שרת הJellyfin עודכן לגרסא {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "הגדרת השרת {0} שונתה", + "MessageServerConfigurationUpdated": "תצורת השרת עודכנה", "MixedContent": "תוכן מעורב", "Movies": "סרטים", "Music": "מוזיקה", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", + "MusicVideos": "קליפים", + "NameInstallFailed": "התקנת {0} נכשלה", + "NameSeasonNumber": "עונה {0}", + "NameSeasonUnknown": "עונה לא ידועה", + "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.", + "NotificationOptionApplicationUpdateAvailable": "קיים עדכון זמין ליישום", + "NotificationOptionApplicationUpdateInstalled": "עדכון ליישום הותקן", + "NotificationOptionAudioPlayback": "ניגון שמע החל", + "NotificationOptionAudioPlaybackStopped": "ניגון שמע הופסק", + "NotificationOptionCameraImageUploaded": "תמונת מצלמה הועלתה", + "NotificationOptionInstallationFailed": "התקנה נכשלה", + "NotificationOptionNewLibraryContent": "תוכן חדש הוסף", + "NotificationOptionPluginError": "כשלון בתוסף", + "NotificationOptionPluginInstalled": "התוסף הותקן", + "NotificationOptionPluginUninstalled": "התוסף הוסר", + "NotificationOptionPluginUpdateInstalled": "העדכון לתוסף הותקן", + "NotificationOptionServerRestartRequired": "יש לאתחל את השרת", + "NotificationOptionTaskFailed": "משימה מתוזמנת נכשלה", + "NotificationOptionUserLockedOut": "משתמש ננעל", + "NotificationOptionVideoPlayback": "ניגון וידאו החל", + "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק", "Photos": "תמונות", - "Playlists": "רשימות ניגון", + "Playlists": "רשימות הפעלה", "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", + "PluginInstalledWithName": "{0} הותקן", + "PluginUninstalledWithName": "{0} הוסר", + "PluginUpdatedWithName": "{0} עודכן", "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "ScheduledTaskFailedWithName": "{0} נכשל", + "ScheduledTaskStartedWithName": "{0} החל", + "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש", + "Shows": "סדרות", + "Songs": "שירים", + "StartupEmbyServerIsLoading": "שרת Jellyfin בהליכי טעינה. אנא נסה שנית בעוד זמן קצר.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", + "SubtitleDownloadFailureFromForItem": "הורדת כתוביות נכשלה מ-{0} עבור {1}", "Sync": "סנכרן", "System": "System", - "TvShows": "TV Shows", + "TvShows": "סדרות טלוויזיה", "User": "User", - "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}", - "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", - "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserCreatedWithName": "המשתמש {0} נוצר", + "UserDeletedWithName": "המשתמש {0} הוסר", + "UserDownloadingItemWithValues": "{0} מוריד את {1}", + "UserLockedOutWithName": "המשתמש {0} ננעל", + "UserOfflineFromDevice": "{0} התנתק מ-{1}", + "UserOnlineFromDevice": "{0} מחובר מ-{1}", + "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", + "UserPolicyUpdatedWithName": "מדיניות המשתמש {0} עודכנה", + "UserStartedPlayingItemWithValues": "{0} מנגן את {1} על {2}", + "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} על {2}", + "ValueHasBeenAddedToLibrary": "{0} התווסף לספריית המדיה שלך", "ValueSpecialEpisodeName": "מיוחד- {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TaskRefreshLibrary": "סרוק ספריית מדיה", + "TaskRefreshChapterImages": "חלץ תמונות פרקים", + "TaskCleanCacheDescription": "מחק קבצי מטמון שלא בשימוש המערכת.", + "TaskCleanCache": "נקה תיקיית מטמון", + "TasksApplicationCategory": "יישום", + "TasksLibraryCategory": "ספרייה", + "TasksMaintenanceCategory": "תחזוקה", + "TaskUpdatePlugins": "עדכן תוספים", + "TaskRefreshPeopleDescription": "מעדכן מטא נתונים עבור שחקנים ובמאים בספריית המדיה שלך.", + "TaskRefreshPeople": "רענן אנשים", + "TaskCleanLogsDescription": "מוחק קבצי יומן בני יותר מ- {0} ימים.", + "TaskCleanLogs": "נקה תיקיית יומן", + "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא נתונים.", + "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.", + "TasksChannelsCategory": "ערוצי אינטרנט", + "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.", + "TaskDownloadMissingSubtitles": "הורד כתוביות חסרות", + "TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.", + "TaskRefreshChannels": "רענן ערוץ", + "TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.", + "TaskCleanTranscode": "נקה תקיית Transcode", + "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 f284b3cd9..d7cda61da 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -5,93 +5,118 @@ "Artists": "Izvođači", "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena", "Books": "Knjige", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {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}", - "Favorites": "Omiljeni", + "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 albuma", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continue Watching", + "HeaderAlbumArtists": "Album od izvođača", + "HeaderContinueWatching": "Nastavi gledati", "HeaderFavoriteAlbums": "Omiljeni albumi", "HeaderFavoriteArtists": "Omiljeni izvođači", "HeaderFavoriteEpisodes": "Omiljene epizode", - "HeaderFavoriteShows": "Omiljene emisije", + "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", - "LabelIpAddressValue": "Ip adresa: {0}", + "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 has been updated to {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} installation failed", + "NameInstallFailed": "{0} neuspješnih instalacija", "NameSeasonNumber": "Sezona {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "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 nije izvršena", - "NotificationOptionNewLibraryContent": "Novi sadržaj je dodan", - "NotificationOptionPluginError": "Dodatak otkazao", + "NameSeasonUnknown": "Sezona nepoznata", + "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.", + "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": "Popisi", + "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} needs to be restarted", - "Shows": "Shows", + "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": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Titlovi prijevoda preuzeti za {0}", - "Sync": "Sink.", - "System": "Sistem", - "TvShows": "TV Shows", + "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": "User policy has been updated for {0}", - "UserStartedPlayingItemWithValues": "{0} je pokrenuo {1}", - "UserStoppedPlayingItemWithValues": "{0} je zaustavio {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", - "ValueSpecialEpisodeName": "Specijal - {0}", - "VersionNumber": "Verzija {0}" + "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": "Posebno - {0}", + "VersionNumber": "Verzija {0}", + "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ž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 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 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", + "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 c0f988abe..85ab1511a 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -1,56 +1,55 @@ { "Albums": "Albumok", "AppDeviceValues": "Program: {0}, Eszköz: {1}", - "Application": "Program", + "Application": "Alkalmazás", "Artists": "Előadók", "AuthenticationSucceededWithUserName": "{0} sikeresen azonosítva", "Books": "Könyvek", - "CameraImageUploadedFrom": "Új kamerakép került feltöltésre {0}", + "CameraImageUploadedFrom": "Új kamerakép került feltöltésre innen: {0}", "Channels": "Csatornák", - "ChapterNameValue": "Jelenet {0}", + "ChapterNameValue": "{0}. jelenet", "Collections": "Gyűjtemények", "DeviceOfflineWithName": "{0} kijelentkezett", "DeviceOnlineWithName": "{0} belépett", - "FailedLoginAttemptWithUserName": "Sikertelen bejelentkezési kísérlet {0}", + "FailedLoginAttemptWithUserName": "Sikertelen bejelentkezési kísérlet tőle: {0}", "Favorites": "Kedvencek", "Folders": "Könyvtárak", "Genres": "Műfajok", - "HeaderAlbumArtists": "Album Előadók", - "HeaderCameraUploads": "Kamera feltöltések", - "HeaderContinueWatching": "Folyamatban lévő filmek", - "HeaderFavoriteAlbums": "Kedvenc Albumok", - "HeaderFavoriteArtists": "Kedvenc Művészek", - "HeaderFavoriteEpisodes": "Kedvenc Epizódok", - "HeaderFavoriteShows": "Kedvenc Műsorok", - "HeaderFavoriteSongs": "Kedvenc Dalok", + "HeaderAlbumArtists": "Előadó albumai", + "HeaderContinueWatching": "Megtekintés folytatása", + "HeaderFavoriteAlbums": "Kedvenc albumok", + "HeaderFavoriteArtists": "Kedvenc előadók", + "HeaderFavoriteEpisodes": "Kedvenc epizódok", + "HeaderFavoriteShows": "Kedvenc sorozatok", + "HeaderFavoriteSongs": "Kedvenc dalok", "HeaderLiveTV": "Élő TV", "HeaderNextUp": "Következik", "HeaderRecordingGroups": "Felvételi csoportok", "HomeVideos": "Házi videók", - "Inherit": "Inherit", + "Inherit": "Örökölt", "ItemAddedWithName": "{0} hozzáadva a könyvtárhoz", "ItemRemovedWithName": "{0} eltávolítva a könyvtárból", "LabelIpAddressValue": "IP cím: {0}", "LabelRunningTimeValue": "Futási idő: {0}", "Latest": "Legújabb", "MessageApplicationUpdated": "Jellyfin Szerver frissítve", - "MessageApplicationUpdatedTo": "Jellyfin Szerver frissítve lett a következőre {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Szerver konfigurációs rész {0} frissítve", + "MessageApplicationUpdatedTo": "Jellyfin Szerver frissítve lett a következőre: {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Szerver konfigurációs rész frissítve: {0}", "MessageServerConfigurationUpdated": "Szerver konfiguráció frissítve", "MixedContent": "Vegyes tartalom", "Movies": "Filmek", "Music": "Zene", - "MusicVideos": "Zenei Videók", + "MusicVideos": "Zenei videóklippek", "NameInstallFailed": "{0} sikertelen telepítés", - "NameSeasonNumber": "Évad {0}", + "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", "NewVersionIsAvailable": "Letölthető a Jellyfin Szerver új verziója.", - "NotificationOptionApplicationUpdateAvailable": "Új programfrissítés érhető el", - "NotificationOptionApplicationUpdateInstalled": "Programfrissítés telepítve", + "NotificationOptionApplicationUpdateAvailable": "Frissítés érhető el az alkalmazáshoz", + "NotificationOptionApplicationUpdateInstalled": "Alkalmazásfrissítés telepítve", "NotificationOptionAudioPlayback": "Audió lejátszás elkezdve", - "NotificationOptionAudioPlaybackStopped": "Audió lejátszás befejezve", + "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", @@ -60,38 +59,66 @@ "NotificationOptionTaskFailed": "Ütemezett feladat hiba", "NotificationOptionUserLockedOut": "Felhasználó tiltva", "NotificationOptionVideoPlayback": "Videó lejátszás elkezdve", - "NotificationOptionVideoPlaybackStopped": "Videó lejátszás befejezve", + "NotificationOptionVideoPlaybackStopped": "Videó lejátszás leállítva", "Photos": "Fényképek", "Playlists": "Lejátszási listák", "Plugin": "Bővítmény", "PluginInstalledWithName": "{0} telepítve", "PluginUninstalledWithName": "{0} eltávolítva", "PluginUpdatedWithName": "{0} frissítve", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} hiba", + "ProviderValue": "Szolgáltató: {0}", + "ScheduledTaskFailedWithName": "{0} sikertelen", "ScheduledTaskStartedWithName": "{0} elkezdve", "ServerNameNeedsToBeRestarted": "{0}-t újra kell indítani", - "Shows": "Műsorok", + "Shows": "Sorozatok", "Songs": "Dalok", - "StartupEmbyServerIsLoading": "A Jellyfin Szerver betöltődik. Kérlek próbáld újra később.", + "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}", - "SubtitlesDownloadedForItem": "Letöltött feliratok a következőhöz {0}", + "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0} ehhez: {1}", "Sync": "Szinkronizál", "System": "Rendszer", - "TvShows": "TV Műsorok", + "TvShows": "TV műsorok", "User": "Felhasználó", "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}", - "UserOnlineFromDevice": "{0} online itt: {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 {0}", - "UserStartedPlayingItemWithValues": "{0} elkezdte játszani a következőt: {1} itt: {2}", - "UserStoppedPlayingItemWithValues": "{0} befejezte a következőt: {1} itt: {2}", + "UserPolicyUpdatedWithName": "A felhasználói házirend frissítve lett neki: {0}", + "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}", - "VersionNumber": "Verzió: {0}" + "VersionNumber": "Verzió: {0}", + "TaskCleanTranscode": "Átkódolási könyvtár ürítése", + "TaskUpdatePluginsDescription": "Letölti és telepíti a frissítéseket azokhoz a bővítményekhez, amelyeknél az automatikus frissítés engedélyezve van.", + "TaskUpdatePlugins": "Bővítmények frissítése", + "TaskRefreshPeopleDescription": "Frissíti a szereplők és a stábok metaadatait a könyvtáradban.", + "TaskRefreshPeople": "Személyek frissítése", + "TaskCleanLogsDescription": "Törli azokat a naplófájlokat, amelyek {0} napnál régebbiek.", + "TaskCleanLogs": "Naplózási könyvtár ürítése", + "TaskRefreshLibraryDescription": "Átvizsgálja a könyvtáraidat új fájlokért és frissíti a metaadatokat.", + "TaskRefreshLibrary": "Média könyvtár beolvasása", + "TaskRefreshChapterImagesDescription": "Miniatűröket generál olyan videókhoz, amely tartalmaz fejezeteket.", + "TaskRefreshChapterImages": "Fejezetek képeinek generálása", + "TaskCleanCacheDescription": "Törli azokat a gyorsítótárazott fájlokat, amikre a rendszernek már nincs szüksége.", + "TaskCleanCache": "Gyorsítótár könyvtárának ürítése", + "TasksChannelsCategory": "Internetes csatornák", + "TasksApplicationCategory": "Alkalmazás", + "TasksLibraryCategory": "Könyvtár", + "TasksMaintenanceCategory": "Karbantartás", + "TaskDownloadMissingSubtitlesDescription": "A metaadat konfiguráció alapján ellenőrzi és letölti a hiányzó feliratokat az internetről.", + "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.", + "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 new file mode 100644 index 000000000..ba3513870 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -0,0 +1,121 @@ +{ + "Albums": "Album", + "AuthenticationSucceededWithUserName": "{0} berhasil diautentikasi", + "AppDeviceValues": "Aplikasi : {0}, Perangkat : {1}", + "LabelRunningTimeValue": "Waktu berjalan: {0}", + "MessageApplicationUpdatedTo": "Jellyfin Server sudah diperbarui ke {0}", + "MessageApplicationUpdated": "Jellyfin Server sudah diperbarui", + "Latest": "Terbaru", + "LabelIpAddressValue": "Alamat IP: {0}", + "ItemRemovedWithName": "{0} sudah dikeluarkan dari pustaka", + "ItemAddedWithName": "{0} telah dimasukkan ke dalam pustaka", + "Inherit": "Warisan", + "HomeVideos": "Video Rumah", + "HeaderRecordingGroups": "Grup Rekaman", + "HeaderNextUp": "Selanjutnya", + "HeaderLiveTV": "TV Live", + "HeaderFavoriteSongs": "Lagu Favorit", + "HeaderFavoriteShows": "Tayangan Favorit", + "HeaderFavoriteEpisodes": "Episode Favorit", + "HeaderFavoriteArtists": "Artis Favorit", + "HeaderFavoriteAlbums": "Album Favorit", + "HeaderContinueWatching": "Lanjut Menonton", + "HeaderAlbumArtists": "Album Artis", + "Genres": "Aliran", + "Folders": "Folder", + "Favorites": "Favorit", + "Collections": "Koleksi", + "Books": "Buku", + "Artists": "Artis", + "Application": "Aplikasi", + "ChapterNameValue": "Bagian {0}", + "Channels": "Saluran", + "TvShows": "Seri TV", + "SubtitleDownloadFailureFromForItem": "Subtitel gagal diunduh dari {0} untuk {1}", + "StartupEmbyServerIsLoading": "Server Jellyfin sedang dimuat. Silakan coba lagi nanti.", + "Songs": "Lagu", + "Playlists": "Daftar putar", + "NotificationOptionPluginUninstalled": "Plugin dihapus", + "MusicVideos": "Video musik", + "VersionNumber": "Versi {0}", + "ValueSpecialEpisodeName": "Spesial - {0}", + "ValueHasBeenAddedToLibrary": "{0} telah ditambahkan ke pustaka media Anda", + "UserStoppedPlayingItemWithValues": "{0} telah selesai memutar {1} pada {2}", + "UserStartedPlayingItemWithValues": "{0} sedang memutar {1} pada {2}", + "UserPolicyUpdatedWithName": "Kebijakan pengguna telah diperbarui untuk {0}", + "UserPasswordChangedWithName": "Kata sandi telah diubah untuk pengguna {0}", + "UserOnlineFromDevice": "{0} sedang daring dari {1}", + "UserOfflineFromDevice": "{0} telah terputus dari {1}", + "UserLockedOutWithName": "Pengguna {0} telah dikunci", + "UserDownloadingItemWithValues": "{0} sedang mengunduh {1}", + "UserDeletedWithName": "Pengguna {0} telah dihapus", + "UserCreatedWithName": "Pengguna {0} telah dibuat", + "User": "Pengguna", + "System": "Sistem", + "Sync": "Sinkron", + "Shows": "Tayangan", + "ServerNameNeedsToBeRestarted": "{0} perlu dimuat ulang", + "ScheduledTaskStartedWithName": "{0} dimulai", + "ScheduledTaskFailedWithName": "{0} gagal", + "ProviderValue": "Penyedia: {0}", + "PluginUpdatedWithName": "{0} telah diperbarui", + "PluginInstalledWithName": "{0} telah dipasang", + "Plugin": "Plugin", + "Photos": "Foto", + "NotificationOptionUserLockedOut": "Pengguna terkunci", + "NotificationOptionTaskFailed": "Kegagalan tugas terjadwal", + "NotificationOptionServerRestartRequired": "Muat ulang server dibutuhkan", + "NotificationOptionPluginUpdateInstalled": "Pembaruan plugin terpasang", + "NotificationOptionPluginInstalled": "Plugin terpasang", + "NotificationOptionPluginError": "Kegagalan plugin", + "NotificationOptionNewLibraryContent": "Konten baru ditambahkan", + "NotificationOptionInstallationFailed": "Kegagalan pemasangan", + "NotificationOptionCameraImageUploaded": "Gambar kamera terunggah", + "NotificationOptionApplicationUpdateInstalled": "Pembaruan aplikasi terpasang", + "NotificationOptionApplicationUpdateAvailable": "Pembaruan aplikasi tersedia", + "NewVersionIsAvailable": "Versi baru dari Jellyfin Server tersedia untuk diunduh.", + "NameSeasonUnknown": "Musim tak diketahui", + "NameSeasonNumber": "Musim {0}", + "NameInstallFailed": "{0} penginstalan gagal", + "Music": "Musik", + "Movies": "Film", + "MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui", + "MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui", + "FailedLoginAttemptWithUserName": "Percobaan login gagal dari {0}", + "CameraImageUploadedFrom": "Sebuah gambar baru telah diunggah dari {0}", + "DeviceOfflineWithName": "{0} telah terputus", + "DeviceOnlineWithName": "{0} telah terhubung", + "NotificationOptionVideoPlaybackStopped": "Pemutaran video berhenti", + "NotificationOptionVideoPlayback": "Pemutaran video dimulai", + "NotificationOptionAudioPlaybackStopped": "Pemutaran audio berhenti", + "NotificationOptionAudioPlayback": "Pemutaran audio dimulai", + "MixedContent": "Konten campuran", + "PluginUninstalledWithName": "{0} telah dihapus", + "TaskRefreshChapterImagesDescription": "Membuat gambar mini untuk video yang memiliki bagian.", + "TaskRefreshChapterImages": "Ekstrak Gambar Bagian", + "TaskCleanCacheDescription": "Menghapus file cache yang tidak lagi dibutuhkan oleh sistem.", + "TaskCleanCache": "Bersihkan Cache Direktori", + "TasksLibraryCategory": "Pustaka", + "TasksMaintenanceCategory": "Perbaikan", + "TasksApplicationCategory": "Aplikasi", + "TaskRefreshPeopleDescription": "Memperbarui metadata untuk aktor dan sutradara di pustaka media Anda.", + "TaskRefreshLibraryDescription": "Memindai Pustaka media Anda untuk mencari file baru dan memperbarui metadata.", + "TasksChannelsCategory": "Saluran Online", + "TaskDownloadMissingSubtitlesDescription": "Mencari di internet untuk subtitle yang hilang berdasarkan konfigurasi metadata.", + "TaskDownloadMissingSubtitles": "Unduh subtitle yang hilang", + "TaskRefreshChannelsDescription": "Segarkan informasi saluran internet.", + "TaskRefreshChannels": "Segarkan Saluran", + "TaskCleanTranscodeDescription": "Menghapus file transcode yang berumur lebih dari satu hari.", + "TaskCleanTranscode": "Bersihkan Direktori Transcode", + "TaskUpdatePluginsDescription": "Unduh dan instal pembaruan untuk plugin yang dikonfigurasi untuk memperbarui secara otomatis.", + "TaskUpdatePlugins": "Perbarui Plugin", + "TaskRefreshPeople": "Muat ulang Orang", + "TaskCleanLogsDescription": "Menghapus file log yang lebih dari {0} hari.", + "TaskCleanLogs": "Bersihkan Log Direktori", + "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 new file mode 100644 index 000000000..b262a8b42 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -0,0 +1,111 @@ +{ + "LabelIpAddressValue": "IP tala: {0}", + "ItemRemovedWithName": "{0} var fjarlægt úr safninu", + "ItemAddedWithName": "{0} var bætt í safnið", + "Inherit": "Erfa", + "HomeVideos": "Heimamyndbönd", + "HeaderRecordingGroups": "Upptökuhópar", + "HeaderNextUp": "Næst á dagskrá", + "HeaderLiveTV": "Sjónvarp í beinni útsendingu", + "HeaderFavoriteSongs": "Uppáhalds Lög", + "HeaderFavoriteShows": "Uppáhalds Sjónvarpsþættir", + "HeaderFavoriteEpisodes": "Uppáhalds Þættir", + "HeaderFavoriteArtists": "Uppáhalds Listamenn", + "HeaderFavoriteAlbums": "Uppáhalds Plötur", + "HeaderContinueWatching": "Halda áfram að horfa", + "HeaderAlbumArtists": "Höfundur plötu", + "Genres": "Tegundir", + "Folders": "Möppur", + "Favorites": "Uppáhalds", + "FailedLoginAttemptWithUserName": "{0} reyndi að auðkenna sig", + "DeviceOnlineWithName": "{0} hefur tengst", + "DeviceOfflineWithName": "{0} hefur aftengst", + "Collections": "Söfn", + "ChapterNameValue": "Kafli {0}", + "Channels": "Stöðvar", + "CameraImageUploadedFrom": "Ný ljósmynd frá myndavél hefur verið hlaðið upp frá {0}", + "Books": "Bækur", + "AuthenticationSucceededWithUserName": "{0} auðkenning tókst", + "Artists": "Listamaður", + "Application": "Forrit", + "AppDeviceValues": "Snjallforrit: {0}, Tæki: {1}", + "Albums": "Plötur", + "Plugin": "Viðbót", + "Photos": "Myndir", + "NotificationOptionVideoPlaybackStopped": "Myndbandafspilun stöðvuð", + "NotificationOptionVideoPlayback": "Myndbandafspilun hafin", + "NotificationOptionUserLockedOut": "Notandi læstur úti", + "NotificationOptionServerRestartRequired": "Endurræsing þjóns er nauðsynileg", + "NotificationOptionPluginUpdateInstalled": "Viðbótar uppfærsla uppsett", + "NotificationOptionPluginUninstalled": "Viðbót fjarlægð", + "NotificationOptionPluginInstalled": "Viðbót sett upp", + "NotificationOptionPluginError": "Bilun í viðbót", + "NotificationOptionInstallationFailed": "Uppsetning tókst ekki", + "NotificationOptionCameraImageUploaded": "Myndavélarmynd hlaðið upp", + "NotificationOptionAudioPlaybackStopped": "Hljóðafspilun stöðvuð", + "NotificationOptionAudioPlayback": "Hljóðafspilun hafin", + "NotificationOptionApplicationUpdateInstalled": "Uppfærsla uppsett", + "NotificationOptionApplicationUpdateAvailable": "Uppfærsla í boði", + "NameSeasonUnknown": "Sería óþekkt", + "NameSeasonNumber": "Sería {0}", + "MixedContent": "Blandað efni", + "MessageServerConfigurationUpdated": "Stillingar þjóns hafa verið uppfærðar", + "MessageApplicationUpdatedTo": "Jellyfin þjónn hefur verið uppfærður í {0}", + "MessageApplicationUpdated": "Jellyfin þjónn hefur verið uppfærður", + "Latest": "Nýjasta", + "LabelRunningTimeValue": "spilunartími: {0}", + "User": "Notandi", + "System": "Kerfi", + "NotificationOptionNewLibraryContent": "Nýju efni bætt við", + "NewVersionIsAvailable": "Ný útgáfa af Jellyfin þjón er fáanleg til niðurhals.", + "NameInstallFailed": "{0} uppsetning mistókst", + "MusicVideos": "Tónlistarmyndbönd", + "Music": "Tónlist", + "Movies": "Kvikmyndir", + "UserDeletedWithName": "Notanda {0} hefur verið eytt", + "UserCreatedWithName": "Notandi {0} hefur verið stofnaður", + "TvShows": "Þættir", + "Sync": "Samstilla", + "Songs": "Lög", + "ServerNameNeedsToBeRestarted": "{0} þarf að endurræsa", + "ScheduledTaskStartedWithName": "{0} hafin", + "ScheduledTaskFailedWithName": "{0} mistókst", + "PluginUpdatedWithName": "{0} var uppfært", + "PluginUninstalledWithName": "{0} var fjarlægt", + "PluginInstalledWithName": "{0} var sett upp", + "NotificationOptionTaskFailed": "Tímasett verkefni mistókst", + "StartupEmbyServerIsLoading": "Jellyfin netþjónnin er að hlaðast. Vinsamlega prufaðu aftur fljótlega.", + "VersionNumber": "Útgáfa {0}", + "ValueHasBeenAddedToLibrary": "{0} hefur verið bætt við í gagnasafnið þitt", + "UserStoppedPlayingItemWithValues": "{0} hefur lokið spilunar af {1} á {2}", + "UserStartedPlayingItemWithValues": "{0} er að spila {1} á {2}", + "UserPolicyUpdatedWithName": "Notandaregla hefur verið uppfærð fyrir {0}", + "UserPasswordChangedWithName": "Lykilorði fyrir notandann {0} hefur verið breytt", + "UserOnlineFromDevice": "{0} hefur verið virkur síðan {1}", + "UserOfflineFromDevice": "{0} hefur aftengst frá {1}", + "UserLockedOutWithName": "Notanda {0} hefur verið heflaður aðgangur", + "UserDownloadingItemWithValues": "{0} Hleður niður {1}", + "SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}", + "ProviderValue": "Veitandi: {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Stilling {0} hefur verið uppfærð á netþjón", + "ValueSpecialEpisodeName": "Sérstakt - {0}", + "Shows": "Sýningar", + "Playlists": "Spilunarlisti", + "TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.", + "TaskRefreshChannels": "Endurhlaða Rásir", + "TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.", + "TaskCleanTranscode": "Hreinsa Umkóðunarmöppu", + "TaskUpdatePluginsDescription": "Sækja og setja upp uppfærslur fyrir viðbætur sem eru stilltar til að uppfæra sjálfkrafa.", + "TaskUpdatePlugins": "Uppfæra viðbætur", + "TaskRefreshPeopleDescription": "Uppfærir lýsigögn fyrir leikara og leikstjóra í miðlasafninu þínu.", + "TaskRefreshLibraryDescription": "Skannar miðlasafnið þitt fyrir nýjum skrám og uppfærir lýsigögn.", + "TaskRefreshLibrary": "Skanna miðlasafn", + "TaskRefreshChapterImagesDescription": "Býr til smámyndir fyrir myndbönd sem hafa kaflaskil.", + "TaskCleanCacheDescription": "Eyðir skrám í skyndiminni sem ekki er lengur þörf fyrir í kerfinu.", + "TaskCleanCache": "Hreinsa skráasafn skyndiminnis", + "TasksChannelsCategory": "Netrásir", + "TasksApplicationCategory": "Forrit", + "TasksLibraryCategory": "Miðlasafn", + "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 a5f1e8f94..5e28cf09f 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -5,20 +5,19 @@ "Artists": "Artisti", "AuthenticationSucceededWithUserName": "{0} autenticato con successo", "Books": "Libri", - "CameraImageUploadedFrom": "È stata caricata una nuova immagine della fotocamera {0}", + "CameraImageUploadedFrom": "È stata caricata una nuova fotografia da {0}", "Channels": "Canali", "ChapterNameValue": "Capitolo {0}", "Collections": "Collezioni", - "DeviceOfflineWithName": "{0} è stato disconnesso", + "DeviceOfflineWithName": "{0} si è disconnesso", "DeviceOnlineWithName": "{0} è connesso", "FailedLoginAttemptWithUserName": "Tentativo di accesso fallito da {0}", "Favorites": "Preferiti", "Folders": "Cartelle", "Genres": "Generi", - "HeaderAlbumArtists": "Artisti Album", - "HeaderCameraUploads": "Caricamenti Fotocamera", + "HeaderAlbumArtists": "Artisti dell'Album", "HeaderContinueWatching": "Continua a guardare", - "HeaderFavoriteAlbums": "Album preferiti", + "HeaderFavoriteAlbums": "Album Preferiti", "HeaderFavoriteArtists": "Artisti Preferiti", "HeaderFavoriteEpisodes": "Episodi Preferiti", "HeaderFavoriteShows": "Serie TV Preferite", @@ -26,25 +25,25 @@ "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", "LabelIpAddressValue": "Indirizzo IP: {0}", "LabelRunningTimeValue": "Durata: {0}", - "Latest": "Più recenti", + "Latest": "Novità", "MessageApplicationUpdated": "Il Server Jellyfin è stato aggiornato", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "MessageApplicationUpdatedTo": "Jellyfin Server è stato aggiornato a {0}", "MessageNamedServerConfigurationUpdatedWithValue": "La sezione {0} della configurazione server è stata aggiornata", "MessageServerConfigurationUpdated": "La configurazione del server è stata aggiornata", "MixedContent": "Contenuto misto", "Movies": "Film", "Music": "Musica", - "MusicVideos": "Video musicali", - "NameInstallFailed": "{0} installation failed", + "MusicVideos": "Video Musicali", + "NameInstallFailed": "{0} installazione fallita", "NameSeasonNumber": "Stagione {0}", - "NameSeasonUnknown": "Stagione sconosciuto", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NameSeasonUnknown": "Stagione sconosciuta", + "NewVersionIsAvailable": "Una nuova versione di Jellyfin Server è disponibile per il download.", "NotificationOptionApplicationUpdateAvailable": "Aggiornamento dell'applicazione disponibile", "NotificationOptionApplicationUpdateInstalled": "Aggiornamento dell'applicazione installato", "NotificationOptionAudioPlayback": "La riproduzione audio è iniziata", @@ -63,20 +62,19 @@ "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", "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} fallito", "ScheduledTaskStartedWithName": "{0} avviati", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Programmi", + "ServerNameNeedsToBeRestarted": "{0} deve essere riavviato", + "Shows": "Serie TV", "Songs": "Canzoni", "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.", "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Sottotitoli scaricati per {0}", + "SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}", "Sync": "Sincronizza", "System": "Sistema", "TvShows": "Serie TV", @@ -85,13 +83,42 @@ "UserDeletedWithName": "L'utente {0} è stato rimosso", "UserDownloadingItemWithValues": "{0} sta scaricando {1}", "UserLockedOutWithName": "L'utente {0} è stato bloccato", - "UserOfflineFromDevice": "{0} è stato disconnesso da {1}", - "UserOnlineFromDevice": "{0} è online da {1}", + "UserOfflineFromDevice": "{0} si è disconnesso su {1}", + "UserOnlineFromDevice": "{0} è online su {1}", "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}", - "UserPolicyUpdatedWithName": "La politica dell'utente è stata aggiornata per {0}", - "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1}", - "UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}", + "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}", - "VersionNumber": "Versione {0}" + "VersionNumber": "Versione {0}", + "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali Internet.", + "TaskDownloadMissingSubtitlesDescription": "Cerca su internet i sottotitoli mancanti basandosi sulle configurazioni dei metadati.", + "TaskDownloadMissingSubtitles": "Scarica i sottotitoli mancanti", + "TaskRefreshChannels": "Aggiorna i canali", + "TaskCleanTranscodeDescription": "Cancella i file di transcode più vecchi di un giorno.", + "TaskCleanTranscode": "Svuota la cartella del transcoding", + "TaskUpdatePluginsDescription": "Scarica e installa gli aggiornamenti per i plugin che sono stati configurati per essere aggiornati contemporaneamente.", + "TaskUpdatePlugins": "Aggiorna i Plugin", + "TaskRefreshPeopleDescription": "Aggiorna i metadati per gli attori e registi nella tua libreria multimediale.", + "TaskRefreshPeople": "Aggiornamento Persone", + "TaskCleanLogsDescription": "Rimuovi i file di log più vecchi di {0} giorni.", + "TaskCleanLogs": "Pulisci la cartella dei log", + "TaskRefreshLibraryDescription": "Analizza la tua libreria multimediale per nuovi file e rinnova i metadati.", + "TaskRefreshLibrary": "Scan Librerie", + "TaskRefreshChapterImagesDescription": "Crea le thumbnail per i video che hanno capitoli.", + "TaskRefreshChapterImages": "Estrai immagini capitolo", + "TaskCleanCacheDescription": "Cancella i file di cache non più necessari al sistema.", + "TaskCleanCache": "Pulisci la directory della cache", + "TasksChannelsCategory": "Canali su Internet", + "TasksApplicationCategory": "Applicazione", + "TasksLibraryCategory": "Libreria", + "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 new file mode 100644 index 000000000..c689bc58a --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -0,0 +1,123 @@ +{ + "Albums": "アルバム", + "AppDeviceValues": "アプリ: {0}, デバイス: {1}", + "Application": "アプリケーション", + "Artists": "アーティスト", + "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", + "Books": "ブックス", + "CameraImageUploadedFrom": "新しいカメライメージが {0}からアップロードされました", + "Channels": "チャンネル", + "ChapterNameValue": "チャプター {0}", + "Collections": "コレクション", + "DeviceOfflineWithName": "{0} が切断されました", + "DeviceOnlineWithName": "{0} が接続されました", + "FailedLoginAttemptWithUserName": "ログインを試行しましたが {0}によって失敗しました", + "Favorites": "お気に入り", + "Folders": "フォルダー", + "Genres": "ジャンル", + "HeaderAlbumArtists": "アーティストのアルバム", + "HeaderContinueWatching": "視聴を続ける", + "HeaderFavoriteAlbums": "お気に入りのアルバム", + "HeaderFavoriteArtists": "お気に入りのアーティスト", + "HeaderFavoriteEpisodes": "お気に入りのエピソード", + "HeaderFavoriteShows": "お気に入りの番組", + "HeaderFavoriteSongs": "お気に入りの曲", + "HeaderLiveTV": "ライブTV", + "HeaderNextUp": "次", + "HeaderRecordingGroups": "レコーディンググループ", + "HomeVideos": "ホームビデオ", + "Inherit": "継承", + "ItemAddedWithName": "{0} をライブラリに追加しました", + "ItemRemovedWithName": "{0} をライブラリから削除しました", + "LabelIpAddressValue": "IPアドレス: {0}", + "LabelRunningTimeValue": "稼働時間: {0}", + "Latest": "最新", + "MessageApplicationUpdated": "Jellyfin Server が更新されました", + "MessageApplicationUpdatedTo": "Jellyfin Server が {0}に更新されました", + "MessageNamedServerConfigurationUpdatedWithValue": "サーバー設定項目の {0} が更新されました", + "MessageServerConfigurationUpdated": "サーバー設定が更新されました", + "MixedContent": "ミックスコンテンツ", + "Movies": "ムービー", + "Music": "ミュージック", + "MusicVideos": "ミュージックビデオ", + "NameInstallFailed": "{0}のインストールに失敗しました", + "NameSeasonNumber": "シーズン {0}", + "NameSeasonUnknown": "不明なシーズン", + "NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロード可能です。", + "NotificationOptionApplicationUpdateAvailable": "アプリケーションの更新があります", + "NotificationOptionApplicationUpdateInstalled": "アプリケーションは最新です", + "NotificationOptionAudioPlayback": "オーディオの再生を開始", + "NotificationOptionAudioPlaybackStopped": "オーディオの再生をストップしました", + "NotificationOptionCameraImageUploaded": "カメライメージがアップロードされました", + "NotificationOptionInstallationFailed": "インストール失敗", + "NotificationOptionNewLibraryContent": "新しいコンテンツを追加しました", + "NotificationOptionPluginError": "プラグインに障害が発生しました", + "NotificationOptionPluginInstalled": "プラグインがインストールされました", + "NotificationOptionPluginUninstalled": "プラグインがアンインストールされました", + "NotificationOptionPluginUpdateInstalled": "プラグインのアップデートをインストールしました", + "NotificationOptionServerRestartRequired": "サーバーを再起動してください", + "NotificationOptionTaskFailed": "スケジュールされていたタスクの失敗", + "NotificationOptionUserLockedOut": "ユーザーはロックされています", + "NotificationOptionVideoPlayback": "ビデオの再生を開始しました", + "NotificationOptionVideoPlaybackStopped": "ビデオを停止しました", + "Photos": "フォト", + "Playlists": "プレイリスト", + "Plugin": "プラグイン", + "PluginInstalledWithName": "{0} がインストールされました", + "PluginUninstalledWithName": "{0} がアンインストールされました", + "PluginUpdatedWithName": "{0} が更新されました", + "ProviderValue": "プロバイダ: {0}", + "ScheduledTaskFailedWithName": "{0} が失敗しました", + "ScheduledTaskStartedWithName": "{0} が開始されました", + "ServerNameNeedsToBeRestarted": "{0} を再起動してください", + "Shows": "番組", + "Songs": "曲", + "StartupEmbyServerIsLoading": "Jellyfin Server は現在読み込み中です。しばらくしてからもう一度お試しください。", + "SubtitleDownloadFailureFromForItem": "{0} から {1}の字幕のダウンロードに失敗しました", + "Sync": "同期", + "System": "システム", + "TvShows": "テレビ番組", + "User": "ユーザー", + "UserCreatedWithName": "ユーザー {0} が作成されました", + "UserDeletedWithName": "User {0} を削除しました", + "UserDownloadingItemWithValues": "{0} が {1} をダウンロードしています", + "UserLockedOutWithName": "ユーザー {0} はロックされています", + "UserOfflineFromDevice": "{0} は {1} から切断しました", + "UserOnlineFromDevice": "{0} は {1} からオンラインになりました", + "UserPasswordChangedWithName": "ユーザー {0} のパスワードは変更されました", + "UserPolicyUpdatedWithName": "ユーザーポリシーが{0}に更新されました", + "UserStartedPlayingItemWithValues": "{0} は {2}で{1} を再生しています", + "UserStoppedPlayingItemWithValues": "{0} は{2}で{1} の再生が終わりました", + "ValueHasBeenAddedToLibrary": "{0}はあなたのメディアライブラリに追加されました", + "ValueSpecialEpisodeName": "スペシャル - {0}", + "VersionNumber": "バージョン {0}", + "TaskCleanLogsDescription": "{0} 日以上前のログを消去します。", + "TaskCleanLogs": "ログの掃除", + "TaskRefreshLibraryDescription": "メディアライブラリをスキャンして新しいファイルを探し、メタデータをリフレッシュします。", + "TaskRefreshLibrary": "メディアライブラリのスキャン", + "TaskCleanCacheDescription": "不要なキャッシュを消去します。", + "TaskCleanCache": "キャッシュを消去", + "TasksChannelsCategory": "ネットチャンネル", + "TasksApplicationCategory": "アプリケーション", + "TasksLibraryCategory": "ライブラリ", + "TasksMaintenanceCategory": "メンテナンス", + "TaskRefreshChannelsDescription": "ネットチャンネルの情報をリフレッシュします。", + "TaskRefreshChannels": "チャンネルのリフレッシュ", + "TaskCleanTranscodeDescription": "1日以上経過したトランスコードファイルを削除します。", + "TaskCleanTranscode": "トランスコードディレクトリの削除", + "TaskUpdatePluginsDescription": "自動更新可能なプラグインのアップデートをダウンロードしてインストールします。", + "TaskUpdatePlugins": "プラグインの更新", + "TaskRefreshPeopleDescription": "メディアライブラリで俳優や監督のメタデータを更新します。", + "TaskRefreshPeople": "俳優や監督のデータの更新", + "TaskDownloadMissingSubtitlesDescription": "メタデータ構成に基づいて、欠落している字幕をインターネットで検索します。", + "TaskRefreshChapterImagesDescription": "チャプターのあるビデオのサムネイルを作成します。", + "TaskRefreshChapterImages": "チャプター画像を抽出する", + "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 658d168e9..d28564a7c 100644 --- a/Emby.Server.Implementations/Localization/Core/kk.json +++ b/Emby.Server.Implementations/Localization/Core/kk.json @@ -1,97 +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úpnusqalyǵyn rastalýy sátti", - "Books": "Kitaptar", - "CameraImageUploadedFrom": "Jańa sýret {0} kamerasynan júktep alyndy", + "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", - "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 ústelindi", - "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} deńgeıge jańartyldy", - "MessageNamedServerConfigurationUpdatedWithValue": "Server teńsheliminiń {0} bólimi jańartyldy", - "MessageServerConfigurationUpdated": "Server teńshelimi 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 keri qotarylǵ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", - "SubtitlesDownloadedForItem": "{0} úshin sýbtıtrler júktelip alyndy", - "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 21808fd18..a37de0748 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -1,97 +1,124 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Genres": "Genres", - "HeaderAlbumArtists": "앨범 아티스트", - "HeaderCameraUploads": "Camera Uploads", + "Albums": "앨범", + "AppDeviceValues": "앱: {0}, 장치: {1}", + "Application": "애플리케이션", + "Artists": "아티스트", + "AuthenticationSucceededWithUserName": "{0}이 성공적으로 인증됨", + "Books": "도서", + "CameraImageUploadedFrom": "{0}에서 새로운 카메라 이미지가 업로드됨", + "Channels": "채널", + "ChapterNameValue": "챕터 {0}", + "Collections": "컬렉션", + "DeviceOfflineWithName": "{0}의 연결 끊김", + "DeviceOnlineWithName": "{0}이 연결됨", + "FailedLoginAttemptWithUserName": "{0}에서 로그인 실패", + "Favorites": "즐겨찾기", + "Folders": "폴더", + "Genres": "장르", + "HeaderAlbumArtists": "아티스트의 앨범", "HeaderContinueWatching": "계속 시청하기", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", + "HeaderFavoriteAlbums": "즐겨찾는 앨범", + "HeaderFavoriteArtists": "즐겨찾는 아티스트", + "HeaderFavoriteEpisodes": "즐겨찾는 에피소드", "HeaderFavoriteShows": "즐겨찾는 쇼", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시후 다시시도 해주세요.", + "HeaderFavoriteSongs": "즐겨찾는 노래", + "HeaderLiveTV": "실시간 TV", + "HeaderNextUp": "다음으로", + "HeaderRecordingGroups": "녹화 그룹", + "HomeVideos": "홈 비디오", + "Inherit": "상속", + "ItemAddedWithName": "{0}가 라이브러리에 추가되었습니다", + "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨", + "LabelIpAddressValue": "IP 주소: {0}", + "LabelRunningTimeValue": "상영 시간: {0}", + "Latest": "최근", + "MessageApplicationUpdated": "Jellyfin 서버가 업데이트되었습니다", + "MessageApplicationUpdatedTo": "Jellyfin 서버가 {0}로 업데이트되었습니다", + "MessageNamedServerConfigurationUpdatedWithValue": "서버 환경 설정 {0} 섹션이 업데이트되었습니다", + "MessageServerConfigurationUpdated": "서버 환경 설정이 업데이트되었습니다", + "MixedContent": "혼합 콘텐츠", + "Movies": "영화", + "Music": "음악", + "MusicVideos": "뮤직비디오", + "NameInstallFailed": "{0} 설치 실패", + "NameSeasonNumber": "시즌 {0}", + "NameSeasonUnknown": "알 수 없는 시즌", + "NewVersionIsAvailable": "새로운 버전의 Jellyfin 서버를 사용할 수 있습니다.", + "NotificationOptionApplicationUpdateAvailable": "애플리케이션 업데이트 가능", + "NotificationOptionApplicationUpdateInstalled": "애플리케이션 업데이트 완료", + "NotificationOptionAudioPlayback": "오디오 재생 시작됨", + "NotificationOptionAudioPlaybackStopped": "오디오 재생 중지됨", + "NotificationOptionCameraImageUploaded": "카메라 이미지가 업로드됨", + "NotificationOptionInstallationFailed": "설치 실패", + "NotificationOptionNewLibraryContent": "새 콘텐츠가 추가됨", + "NotificationOptionPluginError": "플러그인 실패", + "NotificationOptionPluginInstalled": "플러그인 설치됨", + "NotificationOptionPluginUninstalled": "플러그인 제거됨", + "NotificationOptionPluginUpdateInstalled": "플러그인 업데이트 완료", + "NotificationOptionServerRestartRequired": "서버 재시작 필요", + "NotificationOptionTaskFailed": "예약 작업 실패", + "NotificationOptionUserLockedOut": "잠긴 사용자", + "NotificationOptionVideoPlayback": "비디오 재생 시작됨", + "NotificationOptionVideoPlaybackStopped": "비디오 재생 중지됨", + "Photos": "사진", + "Playlists": "재생목록", + "Plugin": "플러그인", + "PluginInstalledWithName": "{0} 설치됨", + "PluginUninstalledWithName": "{0} 제거됨", + "PluginUpdatedWithName": "{0} 업데이트됨", + "ProviderValue": "제공자: {0}", + "ScheduledTaskFailedWithName": "{0} 실패", + "ScheduledTaskStartedWithName": "{0} 시작", + "ServerNameNeedsToBeRestarted": "{0}를 재시작해야합니다", + "Shows": "쇼", + "Songs": "노래", + "StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", - "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "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": "Special - {0}", - "VersionNumber": "Version {0}" + "SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다", + "Sync": "동기화", + "System": "시스템", + "TvShows": "TV 쇼", + "User": "사용자", + "UserCreatedWithName": "사용자 {0} 생성됨", + "UserDeletedWithName": "사용자 {0} 삭제됨", + "UserDownloadingItemWithValues": "{0}이(가) {1}을 다운로드 중입니다", + "UserLockedOutWithName": "유저 {0} 은(는) 잠금처리 되었습니다", + "UserOfflineFromDevice": "{1}에서 {0}의 연결이 끊킴", + "UserOnlineFromDevice": "{0}이 {1}으로 접속", + "UserPasswordChangedWithName": "사용자 {0}의 비밀번호가 변경되었습니다", + "UserPolicyUpdatedWithName": "{0}의 사용자 정책이 업데이트되었습니다", + "UserStartedPlayingItemWithValues": "{2}에서 {0}이 {1} 재생 중", + "UserStoppedPlayingItemWithValues": "{2}에서 {0}이 {1} 재생을 마침", + "ValueHasBeenAddedToLibrary": "{0}가 미디어 라이브러리에 추가되었습니다", + "ValueSpecialEpisodeName": "스페셜 - {0}", + "VersionNumber": "버전 {0}", + "TasksApplicationCategory": "어플리케이션", + "TasksMaintenanceCategory": "유지 보수", + "TaskDownloadMissingSubtitlesDescription": "메타 데이터 기반으로 누락 된 자막이 있는지 인터넷을 검색합니다.", + "TaskDownloadMissingSubtitles": "누락 된 자막 다운로드", + "TaskRefreshChannelsDescription": "인터넷 채널 정보를 새로 고칩니다.", + "TaskRefreshChannels": "채널 새로고침", + "TaskCleanTranscodeDescription": "하루 이상 지난 트랜스 코드 파일을 삭제합니다.", + "TaskCleanTranscode": "트랜스코드 폴더 청소", + "TaskUpdatePluginsDescription": "자동으로 업데이트되도록 구성된 플러그인 업데이트를 다운로드하여 설치합니다.", + "TaskUpdatePlugins": "플러그인 업데이트", + "TaskRefreshPeopleDescription": "미디어 라이브러리에서 배우 및 감독의 메타 데이터를 업데이트합니다.", + "TaskRefreshPeople": "인물 새로고침", + "TaskCleanLogsDescription": "{0} 일이 지난 로그 파일을 삭제합니다.", + "TaskCleanLogs": "로그 폴더 청소", + "TaskRefreshLibraryDescription": "미디어 라이브러리에서 새 파일을 검색하고 메타 데이터를 새로 고칩니다.", + "TaskRefreshLibrary": "미디어 라이브러리 스캔", + "TaskRefreshChapterImagesDescription": "챕터가있는 비디오의 썸네일을 만듭니다.", + "TaskRefreshChapterImages": "챕터 이미지 추출", + "TaskCleanCacheDescription": "시스템에서 더 이상 필요하지 않은 캐시 파일을 삭제합니다.", + "TaskCleanCache": "캐시 폴더 청소", + "TasksChannelsCategory": "인터넷 채널", + "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 558904f06..f3a131d40 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -1,97 +1,122 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", + "Albums": "Albumai", + "AppDeviceValues": "Programa: {0}, Įrenginys: {1}", + "Application": "Programa", + "Artists": "Atlikėjai", + "AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota", + "Books": "Knygos", + "CameraImageUploadedFrom": "Nauja nuotrauka įkelta iš kameros {0}", + "Channels": "Kanalai", + "ChapterNameValue": "Scena{0}", + "Collections": "Kolekcijos", + "DeviceOfflineWithName": "{0} buvo atjungtas", + "DeviceOnlineWithName": "{0} prisijungęs", + "FailedLoginAttemptWithUserName": "{0} - nesėkmingas bandymas prisijungti", + "Favorites": "Mėgstami", + "Folders": "Katalogai", "Genres": "Žanrai", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", + "HeaderAlbumArtists": "Albumo atlikėjai", "HeaderContinueWatching": "Žiūrėti toliau", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", + "HeaderFavoriteAlbums": "Mėgstami Albumai", + "HeaderFavoriteArtists": "Mėgstami Atlikėjai", + "HeaderFavoriteEpisodes": "Mėgstamiausios serijos", + "HeaderFavoriteShows": "Mėgstamiausi serialai", + "HeaderFavoriteSongs": "Mėgstamos dainos", + "HeaderLiveTV": "TV gyvai", + "HeaderNextUp": "Toliau eilėje", + "HeaderRecordingGroups": "Įrašų grupės", + "HomeVideos": "Namų vaizdo įrašai", + "Inherit": "Paveldėti", + "ItemAddedWithName": "{0} - buvo įkeltas į mediateką", + "ItemRemovedWithName": "{0} - buvo pašalinta iš mediatekos", + "LabelIpAddressValue": "IP adresas: {0}", + "LabelRunningTimeValue": "Trukmė: {0}", + "Latest": "Naujausi", + "MessageApplicationUpdated": "\"Jellyfin Server\" atnaujintas", + "MessageApplicationUpdatedTo": "\"Jellyfin Server\" buvo atnaujinta iki {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Serverio nustatymai (skyrius {0}) buvo atnaujinti", + "MessageServerConfigurationUpdated": "Serverio nustatymai buvo atnaujinti", "MixedContent": "Mixed content", "Movies": "Filmai", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", + "Music": "Muzika", + "MusicVideos": "Muzikiniai klipai", + "NameInstallFailed": "{0} diegimo klaida", + "NameSeasonNumber": "Sezonas {0}", + "NameSeasonUnknown": "Sezonas neatpažintas", + "NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.", + "NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai", + "NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti", + "NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas", + "NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas", + "NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti", + "NotificationOptionInstallationFailed": "Diegimo klaida", + "NotificationOptionNewLibraryContent": "Naujas turinys įkeltas", + "NotificationOptionPluginError": "Įskiepio klaida", + "NotificationOptionPluginInstalled": "Įskiepis įdiegtas", + "NotificationOptionPluginUninstalled": "Įskiepis pašalintas", + "NotificationOptionPluginUpdateInstalled": "Įskiepio atnaujinimas įdiegtas", + "NotificationOptionServerRestartRequired": "Reikalingas serverio perleidimas", + "NotificationOptionTaskFailed": "Suplanuotos užduoties klaida", + "NotificationOptionUserLockedOut": "Vartotojas užblokuotas", + "NotificationOptionVideoPlayback": "Vaizdo įrašo atkūrimas pradėtas", + "NotificationOptionVideoPlaybackStopped": "Vaizdo įrašo atkūrimas sustabdytas", + "Photos": "Nuotraukos", + "Playlists": "Grojaraštis", "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", + "PluginInstalledWithName": "{0} buvo įdiegtas", + "PluginUninstalledWithName": "{0} buvo pašalintas", + "PluginUpdatedWithName": "{0} buvo atnaujintas", "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "ScheduledTaskFailedWithName": "{0} klaida", + "ScheduledTaskStartedWithName": "{0} paleista", + "ServerNameNeedsToBeRestarted": "{0} reikia iš naujo paleisti", + "Shows": "Laidos", + "Songs": "Kūriniai", + "StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", + "SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}", "Sync": "Sinchronizuoti", "System": "System", - "TvShows": "TV Shows", + "TvShows": "TV Serialai", "User": "User", - "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}", - "UserStartedPlayingItemWithValues": "{0} is playing {1} on {2}", - "UserStoppedPlayingItemWithValues": "{0} has finished playing {1} on {2}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserCreatedWithName": "Vartotojas {0} buvo sukurtas", + "UserDeletedWithName": "Vartotojas {0} ištrintas", + "UserDownloadingItemWithValues": "{0} siunčiasi {1}", + "UserLockedOutWithName": "Vartotojas {0} užblokuotas", + "UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}", + "UserOnlineFromDevice": "{0} prisijungęs iš {1}", + "UserPasswordChangedWithName": "Slaptažodis pakeistas vartotojui {0}", + "UserPolicyUpdatedWithName": "Vartotojo {0} teisės buvo pakeistos", + "UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}", + "UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}", + "ValueHasBeenAddedToLibrary": "{0} pridėtas į mediateką", "ValueSpecialEpisodeName": "Ypatinga - {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TaskUpdatePluginsDescription": "Atsisiųsti ir įdiegti atnaujinimus priedams kuriem yra nustatytas automatiškas atnaujinimas.", + "TaskUpdatePlugins": "Atnaujinti Priedus", + "TaskDownloadMissingSubtitlesDescription": "Ieško internete trūkstamų subtitrų remiantis metaduomenų konfigūracija.", + "TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.", + "TaskCleanTranscode": "Išvalyti Perkodavimo Direktorija", + "TaskRefreshLibraryDescription": "Ieškoti naujų failų jūsų mediatekoje ir atnaujina metaduomenis.", + "TaskRefreshLibrary": "Skenuoti Mediateka", + "TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus", + "TaskRefreshChannelsDescription": "Atnaujina internetinių kanalų informacija.", + "TaskRefreshChannels": "Atnaujinti Kanalus", + "TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų mediatekoje.", + "TaskRefreshPeople": "Atnaujinti Žmones", + "TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.", + "TaskCleanLogs": "Išvalyti Žurnalą", + "TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašam, kurie turi scenas.", + "TaskRefreshChapterImages": "Ištraukti Scenų Paveikslus", + "TaskCleanCache": "Išvalyti Talpyklą", + "TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.", + "TasksChannelsCategory": "Internetiniai Kanalai", + "TasksApplicationCategory": "Programa", + "TasksLibraryCategory": "Mediateka", + "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 new file mode 100644 index 000000000..443a74a10 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -0,0 +1,123 @@ +{ + "ServerNameNeedsToBeRestarted": "{0} ir vajadzīgs restarts", + "NotificationOptionTaskFailed": "Plānota uzdevuma kļūme", + "HeaderRecordingGroups": "Ierakstu Grupas", + "UserPolicyUpdatedWithName": "Lietotāju politika atjaunota priekš {0}", + "SubtitleDownloadFailureFromForItem": "Subtitru lejupielāde no {0} priekš {1} neizdevās", + "NotificationOptionVideoPlaybackStopped": "Video atskaņošana apturēta", + "NotificationOptionVideoPlayback": "Video atskaņošana sākta", + "NotificationOptionInstallationFailed": "Instalācija neizdevās", + "AuthenticationSucceededWithUserName": "{0} veiksmīgi autentificējies", + "ValueSpecialEpisodeName": "Speciālais - {0}", + "ScheduledTaskStartedWithName": "{0} iesākts", + "ScheduledTaskFailedWithName": "{0} neizdevās", + "Photos": "Attēli", + "NotificationOptionUserLockedOut": "Lietotājs bloķēts", + "LabelRunningTimeValue": "Garums: {0}", + "Inherit": "Mantot", + "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", + "VersionNumber": "Versija {0}", + "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}", + "UserOnlineFromDevice": "{0} ir tiešsaistē no {1}", + "UserOfflineFromDevice": "{0} ir atvienojies no {1}", + "UserLockedOutWithName": "Lietotājs {0} ir ticis bloķēts", + "UserDownloadingItemWithValues": "{0} lejupielādē {1}", + "UserDeletedWithName": "Lietotājs {0} ir izdzēsts", + "UserCreatedWithName": "Lietotājs {0} ir ticis izveidots", + "User": "Lietotājs", + "TvShows": "TV Raidījumi", + "Sync": "Sinhronizācija", + "System": "Sistēma", + "StartupEmbyServerIsLoading": "Jellyfin Serveris lādējas. Lūdzu mēģiniet vēlreiz pēc brīža.", + "Songs": "Dziesmas", + "Shows": "Raidījumi", + "PluginUpdatedWithName": "{0} tika atjaunots", + "PluginUninstalledWithName": "{0} tika noņemts", + "PluginInstalledWithName": "{0} tika uzstādīts", + "Plugin": "Paplašinājums", + "Playlists": "Atskaņošanas Saraksti", + "MixedContent": "Jaukts saturs", + "HomeVideos": "Mājas Video", + "HeaderNextUp": "Nākamais", + "ChapterNameValue": "Nodaļa {0}", + "Application": "Lietotne", + "NotificationOptionServerRestartRequired": "Vajadzīgs servera restarts", + "NotificationOptionPluginUpdateInstalled": "Paplašinājuma atjauninājums uzstādīts", + "NotificationOptionPluginUninstalled": "Paplašinājums noņemts", + "NotificationOptionPluginInstalled": "Paplašinājums uzstādīts", + "NotificationOptionPluginError": "Paplašinājuma kļūda", + "NotificationOptionNewLibraryContent": "Jauns saturs pievienots", + "NotificationOptionCameraImageUploaded": "Kameras attēls augšupielādēts", + "NotificationOptionAudioPlaybackStopped": "Audio atskaņošana apturēta", + "NotificationOptionAudioPlayback": "Audio atskaņošana sākta", + "NotificationOptionApplicationUpdateInstalled": "Lietotnes atjauninājums uzstādīts", + "NotificationOptionApplicationUpdateAvailable": "Lietotnes atjauninājums pieejams", + "NewVersionIsAvailable": "Lejupielādei ir pieejama jauna Jellyfin Server versija.", + "NameSeasonUnknown": "Nezināma Sezona", + "NameSeasonNumber": "Sezona {0}", + "NameInstallFailed": "{0} instalācija neizdevās", + "MusicVideos": "Mūzikas video", + "Music": "Mūzika", + "Movies": "Filmas", + "MessageServerConfigurationUpdated": "Servera konfigurācija ir tikusi atjaunota", + "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} ir tikusi atjaunota", + "MessageApplicationUpdatedTo": "Jellyfin Server ir ticis atjaunots uz {0}", + "MessageApplicationUpdated": "Jellyfin Server ir ticis atjaunots", + "Latest": "Jaunākais", + "LabelIpAddressValue": "IP adrese: {0}", + "ItemRemovedWithName": "{0} tika noņemts no bibliotēkas", + "ItemAddedWithName": "{0} tika pievienots bibliotēkai", + "HeaderLiveTV": "Tiešraides TV", + "HeaderContinueWatching": "Turpināt Skatīšanos", + "HeaderAlbumArtists": "Albumu Izpildītāji", + "Genres": "Žanri", + "Folders": "Mapes", + "Favorites": "Favorīti", + "FailedLoginAttemptWithUserName": "Neizdevies pieslēgšanās mēģinājums no {0}", + "DeviceOnlineWithName": "{0} ir pievienojies", + "DeviceOfflineWithName": "{0} ir atvienojies", + "Collections": "Kolekcijas", + "Channels": "Kanāli", + "CameraImageUploadedFrom": "Jauns kameras attēls ir ticis augšupielādēts no {0}", + "Books": "Grāmatas", + "Artists": "Izpildītāji", + "Albums": "Albumi", + "ProviderValue": "Provider: {0}", + "HeaderFavoriteSongs": "Dziesmu Favorīti", + "HeaderFavoriteShows": "Raidījumu Favorīti", + "HeaderFavoriteEpisodes": "Episožu Favorīti", + "HeaderFavoriteArtists": "Izpildītāju Favorīti", + "HeaderFavoriteAlbums": "Albumu Favorīti", + "TaskCleanCacheDescription": "Nodzēš keša datnes, kas vairs nav sistēmai vajadzīgas.", + "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus", + "TasksApplicationCategory": "Lietotne", + "TasksLibraryCategory": "Bibliotēka", + "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", + "TaskCleanTranscodeDescription": "Izdzēš trans-kodēšanas datnes, kas ir vecākas par vienu dienu.", + "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 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ē 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", + "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 new file mode 100644 index 000000000..b780ef498 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -0,0 +1,101 @@ +{ + "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": "Нова верзија од Jellyfin е возможна за спуштање.", + "NameSeasonUnknown": "Непозната Сезона", + "NameSeasonNumber": "Сезона {0}", + "NameInstallFailed": "{0} неуспешна инсталација", + "MusicVideos": "Музички видеа", + "Music": "Музика", + "Movies": "Филмови", + "MixedContent": "Мешана содржина", + "MessageServerConfigurationUpdated": "Серверската конфигурација беше надградена", + "MessageNamedServerConfigurationUpdatedWithValue": "Секцијата на конфигурација на сервер {0} беше надоградена", + "MessageApplicationUpdatedTo": "Jellyfin беше надограден до {0}", + "MessageApplicationUpdated": "Jellyfin Серверот беше надограден", + "Latest": "Последно", + "LabelRunningTimeValue": "Време на работа: {0}", + "LabelIpAddressValue": "ИП Адреса: {0}", + "ItemRemovedWithName": "{0} е избришано до Библиотеката", + "ItemAddedWithName": "{0} беше додадено во Библиотеката", + "Inherit": "Следно", + "HomeVideos": "Домашни Видеа", + "HeaderRecordingGroups": "Групи на снимање", + "HeaderNextUp": "Следно", + "HeaderLiveTV": "ТВ", + "HeaderFavoriteSongs": "Омилени Песни", + "HeaderFavoriteShows": "Омилени Серии", + "HeaderFavoriteEpisodes": "Омилени Епизоди", + "HeaderFavoriteArtists": "Омилени Изведувачи", + "HeaderFavoriteAlbums": "Омилени Албуми", + "HeaderContinueWatching": "Продолжи со гледање", + "HeaderAlbumArtists": "Изведувачи од Албуми", + "Genres": "Жанрови", + "Folders": "Папки", + "Favorites": "Омилени", + "FailedLoginAttemptWithUserName": "Неуспешно поврзување од {0}", + "DeviceOnlineWithName": "{0} е приклучен", + "DeviceOfflineWithName": "{0} се исклучи", + "Collections": "Колекции", + "ChapterNameValue": "Дел {0}", + "Channels": "Канали", + "CameraImageUploadedFrom": "Нова слика од камера беше поставена од {0}", + "Books": "Книги", + "AuthenticationSucceededWithUserName": "{0} успешно поврзан", + "Artists": "Изведувач", + "Application": "Апликација", + "AppDeviceValues": "Аплиакција: {0}, Уред: {1}", + "Albums": "Албуми", + "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 Server се пушта. Ве молиме причекајте.", + "Songs": "Песни", + "Shows": "Серии", + "ServerNameNeedsToBeRestarted": "{0} треба да се рестартира", + "ScheduledTaskStartedWithName": "{0} започна", + "TaskRefreshChapterImages": "Извези Слики од Поглавје", + "TaskCleanCacheDescription": "Ги брише кешираните фајлови што не се повеќе потребни од системот.", + "TaskCleanCache": "Исчисти Го Кешот", + "TasksChannelsCategory": "Интернет Канали", + "TasksApplicationCategory": "Апликација", + "TasksLibraryCategory": "Библиотека", + "TasksMaintenanceCategory": "Одржување" +} 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 new file mode 100644 index 000000000..fdb4171b5 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -0,0 +1,62 @@ +{ + "Books": "पुस्तकं", + "Artists": "संगीतकार", + "Albums": "अल्बम", + "Playlists": "प्लेलिस्ट", + "HeaderAlbumArtists": "अल्बम संगीतकार", + "Folders": "फोल्डर", + "HeaderFavoriteEpisodes": "आवडते भाग", + "HeaderFavoriteSongs": "आवडती गाणी", + "Movies": "चित्रपट", + "HeaderFavoriteArtists": "आवडते संगीतकार", + "Shows": "कार्यक्रम", + "HeaderFavoriteAlbums": "आवडते अल्बम", + "Channels": "वाहिन्या", + "ValueSpecialEpisodeName": "विशेष - {0}", + "HeaderFavoriteShows": "आवडते कार्यक्रम", + "Favorites": "आवडीचे", + "HeaderNextUp": "यानंतर", + "Songs": "गाणी", + "HeaderLiveTV": "लाइव्ह टीव्ही", + "Genres": "जाँनरे", + "Photos": "चित्र", + "TaskDownloadMissingSubtitles": "नसलेले सबटायटल डाउनलोड करा", + "TaskCleanTranscodeDescription": "एक दिवसापेक्षा जुन्या ट्रान्सकोड फायली काढून टाका.", + "TaskCleanTranscode": "ट्रान्सकोड डिरेक्टरी साफ करून टाका", + "TaskUpdatePlugins": "प्लगइन अपडेट करा", + "TaskCleanLogs": "लॉग डिरेक्टरी साफ करून टाका", + "TaskCleanCache": "कॅश डिरेक्टरी साफ करून टाका", + "TasksChannelsCategory": "इंटरनेट वाहिन्या", + "TasksApplicationCategory": "अॅप्लिकेशन", + "TasksLibraryCategory": "संग्रहालय", + "VersionNumber": "आवृत्ती {0}", + "UserPasswordChangedWithName": "{0} या प्रयोक्त्याचे पासवर्ड बदलण्यात आले आहे", + "UserOnlineFromDevice": "{0} हे {1} येथून ऑनलाइन आहेत", + "UserDeletedWithName": "प्रयोक्ता {0} काढून टाकण्यात आले आहे", + "UserCreatedWithName": "प्रयोक्ता {0} बनवण्यात आले आहे", + "User": "प्रयोक्ता", + "TvShows": "टीव्ही कार्यक्रम", + "StartupEmbyServerIsLoading": "जेलिफिन सर्व्हर लोड होत आहे. कृपया थोड्या वेळात पुन्हा प्रयत्न करा.", + "Plugin": "प्लगइन", + "NotificationOptionCameraImageUploaded": "कॅमेरा चित्र अपलोड केले आहे", + "NotificationOptionApplicationUpdateInstalled": "अॅप्लिकेशन अपडेट इन्स्टॉल केले आहे", + "NotificationOptionApplicationUpdateAvailable": "अॅप्लिकेशन अपडेट उपलब्ध आहे", + "NewVersionIsAvailable": "जेलिफिन सर्व्हरची एक नवीन आवृत्ती डाउनलोड करण्यास उपलब्ध आहे.", + "NameSeasonUnknown": "अज्ञात सीझन", + "NameSeasonNumber": "सीझन {0}", + "MusicVideos": "संगीत व्हिडीयो", + "Music": "संगीत", + "MessageApplicationUpdatedTo": "जेलिफिन सर्व्हर अपडेट होऊन {0} आवृत्तीवर पोहोचला आहे", + "MessageApplicationUpdated": "जेलिफिन सर्व्हर अपडेट केला गेला आहे", + "Latest": "नवीनतम", + "LabelIpAddressValue": "आयपी पत्ता: {0}", + "ItemRemovedWithName": "{0} हे संग्रहालयातून काढून टाकण्यात आले", + "ItemAddedWithName": "{0} हे संग्रहालयात जोडले गेले", + "HomeVideos": "घरचे व्हिडीयो", + "HeaderRecordingGroups": "रेकॉर्डिंग गट", + "CameraImageUploadedFrom": "एक नवीन कॅमेरा चित्र {0} येथून अपलोड केले आहे", + "Application": "अॅप्लिकेशन", + "AppDeviceValues": "अॅप: {0}, यंत्र: {1}", + "Collections": "संग्रह", + "ChapterNameValue": "धडा {0}" +} diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index c10fbe58f..b2dcf270c 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -1,97 +1,113 @@ { "Albums": "Album-album", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artis-artis", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", + "AppDeviceValues": "Apl: {0}, Peranti: {1}", + "Application": "Aplikasi", + "Artists": "Artis", + "AuthenticationSucceededWithUserName": "{0} berjaya disahkan", "Books": "Buku-buku", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", + "CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}", + "Channels": "Saluran", + "ChapterNameValue": "Bab {0}", + "Collections": "Koleksi", + "DeviceOfflineWithName": "{0} telah diputuskan sambungan", + "DeviceOnlineWithName": "{0} telah disambung", + "FailedLoginAttemptWithUserName": "Cubaan log masuk gagal dari {0}", + "Favorites": "Kegemaran", + "Folders": "Fail-fail", + "Genres": "Genre-genre", + "HeaderAlbumArtists": "Album Artis-artis", + "HeaderContinueWatching": "Terus Menonton", + "HeaderFavoriteAlbums": "Album-album Kegemaran", + "HeaderFavoriteArtists": "Artis-artis Kegemaran", + "HeaderFavoriteEpisodes": "Episod-episod Kegemaran", + "HeaderFavoriteShows": "Rancangan-rancangan Kegemaran", + "HeaderFavoriteSongs": "Lagu-lagu Kegemaran", + "HeaderLiveTV": "TV Siaran Langsung", + "HeaderNextUp": "Seterusnya", + "HeaderRecordingGroups": "Kumpulan-kumpulan Rakaman", + "HomeVideos": "Video Personal", + "Inherit": "Mewarisi", + "ItemAddedWithName": "{0} telah ditambahkan ke dalam pustaka", + "ItemRemovedWithName": "{0} telah dibuang daripada pustaka", + "LabelIpAddressValue": "Alamat IP: {0}", + "LabelRunningTimeValue": "Masa berjalan: {0}", + "Latest": "Terbaru", + "MessageApplicationUpdated": "Jellyfin Server telah dikemas kini", + "MessageApplicationUpdatedTo": "Jellyfin Server telah dikemas kini ke {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Konfigurasi pelayan di bahagian {0} telah dikemas kini", + "MessageServerConfigurationUpdated": "Konfigurasi pelayan telah dikemas kini", + "MixedContent": "Kandungan campuran", + "Movies": "Filem", + "Music": "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": "Kemas kini aplikasi telah dipasang", + "NotificationOptionAudioPlayback": "Ulangmain audio bermula", + "NotificationOptionAudioPlaybackStopped": "Ulangmain audio dihentikan", + "NotificationOptionCameraImageUploaded": "Imej kamera telah dimuatnaik", + "NotificationOptionInstallationFailed": "Pemasangan gagal", + "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", "PluginUpdatedWithName": "{0} was updated", "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", + "ScheduledTaskFailedWithName": "{0} gagal", + "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}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", + "SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}", "Sync": "Sync", - "System": "System", + "System": "Sistem", "TvShows": "TV Shows", "User": "User", "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": "Special - {0}", - "VersionNumber": "Version {0}" + "ValueSpecialEpisodeName": "Khas - {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 dbda794ad..317bdcfcb 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -1,11 +1,11 @@ { "Albums": "Album", - "AppDeviceValues": "App:{0}, Enhet {1}", - "Application": "Applikasjon", + "AppDeviceValues": "App:{0}, Enhet: {1}", + "Application": "Program", "Artists": "Artister", - "AuthenticationSucceededWithUserName": "{0} vellykkede autentisert", + "AuthenticationSucceededWithUserName": "{0} har logget inn", "Books": "Bøker", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "Et nytt kamerabilde er lastet opp fra {0}", "Channels": "Kanaler", "ChapterNameValue": "Kapittel {0}", "Collections": "Samlinger", @@ -14,72 +14,70 @@ "FailedLoginAttemptWithUserName": "Mislykket påloggingsforsøk fra {0}", "Favorites": "Favoritter", "Folders": "Mapper", - "Genres": "Sjanger", - "HeaderAlbumArtists": "Albumartist", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Forsett og see på", - "HeaderFavoriteAlbums": "Favoritt albumer", + "Genres": "Sjangre", + "HeaderAlbumArtists": "Albumartister", + "HeaderContinueWatching": "Fortsett å se", + "HeaderFavoriteAlbums": "Favorittalbum", "HeaderFavoriteArtists": "Favorittartister", - "HeaderFavoriteEpisodes": "Favoritt episode", + "HeaderFavoriteEpisodes": "Favorittepisoder", "HeaderFavoriteShows": "Favorittserier", "HeaderFavoriteSongs": "Favorittsanger", - "HeaderLiveTV": "Direkte TV", + "HeaderLiveTV": "Direkte-TV", "HeaderNextUp": "Neste", - "HeaderRecordingGroups": "Opptak Grupper", - "HomeVideos": "Hjemmelaget filmer", + "HeaderRecordingGroups": "Opptaksgrupper", + "HomeVideos": "Hjemmelagde filmer", "Inherit": "Arve", "ItemAddedWithName": "{0} ble lagt til i biblioteket", "ItemRemovedWithName": "{0} ble fjernet fra biblioteket", - "LabelIpAddressValue": "IP adresse: {0}", - "LabelRunningTimeValue": "Løpetid {0}", + "LabelIpAddressValue": "IP-adresse: {0}", + "LabelRunningTimeValue": "Spilletid {0}", "Latest": "Siste", - "MessageApplicationUpdated": "Jellyfin server har blitt oppdatert", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server konfigurasjon seksjon {0} har blitt oppdatert", - "MessageServerConfigurationUpdated": "Server konfigurasjon 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} installation failed", + "NameInstallFailed": "Installasjonen av {0} mislyktes", "NameSeasonNumber": "Sesong {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Applikasjon oppdatering tilgjengelig", - "NotificationOptionApplicationUpdateInstalled": "Applikasjon oppdatering installert.", - "NotificationOptionAudioPlayback": "Lyd tilbakespilling startet", - "NotificationOptionAudioPlaybackStopped": "Lyd avspilling stoppet", - "NotificationOptionCameraImageUploaded": "Kamera bilde lastet opp", - "NotificationOptionInstallationFailed": "Installasjon feil", - "NotificationOptionNewLibraryContent": "Ny innhold er lagt til", - "NotificationOptionPluginError": "Plugin feil", - "NotificationOptionPluginInstalled": "Plugin installert", - "NotificationOptionPluginUninstalled": "Plugin avinstallert", - "NotificationOptionPluginUpdateInstalled": "Plugin oppdatering installert", - "NotificationOptionServerRestartRequired": "Server omstart er nødvendig", - "NotificationOptionTaskFailed": "Feil under utføring av planlagt oppgaver", + "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": "Installasjonen feilet", + "NotificationOptionNewLibraryContent": "Nytt innhold lagt til", + "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": "Video tilbakespilling startet", - "NotificationOptionVideoPlaybackStopped": "Video avspilling stoppet", - "Photos": "BIlder", - "Playlists": "Spilleliste", - "Plugin": "Plugin", + "NotificationOptionVideoPlayback": "Videoavspilling startet", + "NotificationOptionVideoPlaybackStopped": "Videoavspilling stoppet", + "Photos": "Bilder", + "Playlists": "Spillelister", + "Plugin": "Programvareutvidelse", "PluginInstalledWithName": "{0} ble installert", "PluginUninstalledWithName": "{0} ble avinstallert", "PluginUpdatedWithName": "{0} ble oppdatert", - "ProviderValue": "Leverandører: {0}", - "ScheduledTaskFailedWithName": "{0} Mislykkes", - "ScheduledTaskStartedWithName": "{0} Startet", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Programmer", + "ProviderValue": "Leverandør: {0}", + "ScheduledTaskFailedWithName": "{0} mislykkes", + "ScheduledTaskStartedWithName": "{0} startet", + "ServerNameNeedsToBeRestarted": "{0} må startes på nytt", + "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": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Undertekster lastet ned for {0}", - "Sync": "Synk", + "SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}", + "Sync": "Synkroniser", "System": "System", - "TvShows": "TV Shows", + "TvShows": "TV-serier", "User": "Bruker", "UserCreatedWithName": "Bruker {0} er opprettet", "UserDeletedWithName": "Bruker {0} har blitt slettet", @@ -88,10 +86,39 @@ "UserOfflineFromDevice": "{0} har koblet fra {1}", "UserOnlineFromDevice": "{0} er tilkoblet fra {1}", "UserPasswordChangedWithName": "Passordet for {0} er oppdatert", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", - "UserStartedPlayingItemWithValues": "{0} har startet avspilling {1}", - "UserStoppedPlayingItemWithValues": "{0} har stoppet avspilling {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", - "ValueSpecialEpisodeName": "Spesial - {0}", - "VersionNumber": "Versjon {0}" + "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": "Internettkanaler", + "TasksApplicationCategory": "Applikasjon", + "TasksLibraryCategory": "Bibliotek", + "TasksMaintenanceCategory": "Vedlikehold", + "TaskCleanCache": "Tøm hurtigbuffer", + "TaskRefreshLibrary": "Skann mediebibliotek", + "TaskRefreshChapterImagesDescription": "Lager forhåndsvisningsbilder for videoer som har kapitler.", + "TaskRefreshChapterImages": "Trekk ut kapittelbilder", + "TaskCleanCacheDescription": "Sletter mellomlagrede filer som ikke lengre trengs av systemet.", + "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 programvareutvidelser som er stilt inn til å oppdatere automatisk.", + "TaskUpdatePlugins": "Oppdater programvareutvidelse", + "TaskRefreshPeopleDescription": "Oppdaterer metadata for skuespillere og regissører i mediebiblioteket ditt.", + "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.", + "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", + "TaskOptimizeDatabaseDescription": "Komprimerer database og frigjør plass. Denne prosessen kan forbedre ytelsen etter skanning av bibliotek eller andre handlinger som fører til databaseendringer." } diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json new file mode 100644 index 000000000..8e820d40c --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ne.json @@ -0,0 +1,85 @@ +{ + "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": "आईपी ठेगाना: {0}", + "ItemRemovedWithName": "{0}लाई पुस्तकालयबाट हटाईयो", + "ItemAddedWithName": "{0} लाईब्रेरीमा थपियो", + "Inherit": "इनहेरिट", + "HomeVideos": "घरेलु भिडियोहरू", + "HeaderRecordingGroups": "रेकर्ड समूहहरू", + "HeaderNextUp": "आगामी", + "HeaderLiveTV": "प्रत्यक्ष टिभी", + "HeaderFavoriteSongs": "मनपर्ने गीतहरू", + "HeaderFavoriteShows": "मनपर्ने कार्यक्रमहरू", + "HeaderFavoriteEpisodes": "मनपर्ने एपिसोडहरू", + "HeaderFavoriteArtists": "मनपर्ने कलाकारहरू", + "HeaderFavoriteAlbums": "मनपर्ने एल्बमहरू", + "HeaderContinueWatching": "हेर्न जारी राख्नुहोस्", + "HeaderAlbumArtists": "एल्बमका कलाकारहरू", + "Genres": "विधाहरू", + "Folders": "फोल्डरहरू", + "Favorites": "मनपर्ने", + "FailedLoginAttemptWithUserName": "{0}को लग इन प्रयास असफल", + "DeviceOnlineWithName": "{0}को साथ जडित", + "DeviceOfflineWithName": "{0}बाट विच्छेदन भयो", + "Collections": "संग्रह", + "ChapterNameValue": "अध्याय {0}", + "Channels": "च्यानलहरू", + "AppDeviceValues": "अनुप्रयोग: {0}, उपकरण: {1}", + "AuthenticationSucceededWithUserName": "{0} सफलतापूर्वक प्रमाणीकरण गरियो", + "CameraImageUploadedFrom": "{0}बाट नयाँ क्यामेरा छवि अपलोड गरिएको छ", + "Books": "पुस्तकहरु", + "Artists": "कलाकारहरू", + "Application": "अनुप्रयोगहरू", + "Albums": "एल्बमहरू", + "TasksLibraryCategory": "पुस्तकालय", + "TasksApplicationCategory": "अनुप्रयोग", + "TasksMaintenanceCategory": "मर्मत", + "UserPolicyUpdatedWithName": "प्रयोगकर्ता नीति को लागी अद्यावधिक गरिएको छ {0}", + "UserPasswordChangedWithName": "पासवर्ड प्रयोगकर्ताका लागि परिवर्तन गरिएको छ {0}", + "UserOnlineFromDevice": "{0} बाट अनलाइन छ {1}", + "UserOfflineFromDevice": "{0} बाट विच्छेदन भएको छ {1}", + "UserLockedOutWithName": "प्रयोगकर्ता {0} लक गरिएको छ", + "UserDeletedWithName": "प्रयोगकर्ता {0} हटाइएको छ", + "UserCreatedWithName": "प्रयोगकर्ता {0} सिर्जना गरिएको छ", + "User": "प्रयोगकर्ता", + "PluginInstalledWithName": "", + "StartupEmbyServerIsLoading": "Jellyfin सर्भर लोड हुँदैछ। कृपया छिट्टै फेरि प्रयास गर्नुहोस्।", + "Songs": "गीतहरू", + "Shows": "शोहरू", + "ServerNameNeedsToBeRestarted": "{0} लाई पुन: सुरु गर्नु पर्छ", + "ScheduledTaskStartedWithName": "{0} सुरु भयो", + "ScheduledTaskFailedWithName": "{0} असफल", + "ProviderValue": "प्रदायक: {0}", + "Plugin": "प्लगइनहरू", + "Playlists": "प्लेलिस्टहरू", + "Photos": "तस्बिरहरु", + "NotificationOptionVideoPlaybackStopped": "भिडियो प्लेब्याक रोकियो", + "NotificationOptionVideoPlayback": "भिडियो प्लेब्याक सुरु भयो" +} diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 24af1839f..79f921bcb 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -3,20 +3,19 @@ "AppDeviceValues": "App: {0}, Apparaat: {1}", "Application": "Toepassing", "Artists": "Artiesten", - "AuthenticationSucceededWithUserName": "{0} is succesvol geverifieerd", + "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", "Books": "Boeken", - "CameraImageUploadedFrom": "Er is een nieuwe foto toegevoegd via {0}", + "CameraImageUploadedFrom": "Nieuwe camera afbeelding toegevoegd vanaf {0}", "Channels": "Kanalen", "ChapterNameValue": "Hoofdstuk {0}", - "Collections": "Collecties", - "DeviceOfflineWithName": "{0} heeft de verbinding verbroken", + "Collections": "Verzamelingen", + "DeviceOfflineWithName": "Verbinding met {0} is verbroken", "DeviceOnlineWithName": "{0} is verbonden", "FailedLoginAttemptWithUserName": "Mislukte aanmeld poging van {0}", "Favorites": "Favorieten", "Folders": "Mappen", "Genres": "Genres", - "HeaderAlbumArtists": "Albumartiesten", - "HeaderCameraUploads": "Camera-uploads", + "HeaderAlbumArtists": "Artiests Album", "HeaderContinueWatching": "Kijken hervatten", "HeaderFavoriteAlbums": "Favoriete albums", "HeaderFavoriteArtists": "Favoriete artiesten", @@ -26,11 +25,11 @@ "HeaderLiveTV": "Live TV", "HeaderNextUp": "Volgende", "HeaderRecordingGroups": "Opnamegroepen", - "HomeVideos": "Start 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}", + "LabelIpAddressValue": "IP-adres: {0}", "LabelRunningTimeValue": "Looptijd: {0}", "Latest": "Nieuwste", "MessageApplicationUpdated": "Jellyfin Server is bijgewerkt", @@ -58,7 +57,7 @@ "NotificationOptionPluginUpdateInstalled": "Plug-in-update geïnstalleerd", "NotificationOptionServerRestartRequired": "Server herstart nodig", "NotificationOptionTaskFailed": "Geplande taak mislukt", - "NotificationOptionUserLockedOut": "Gebruikersaccount vergrendeld", + "NotificationOptionUserLockedOut": "Gebruiker is vergrendeld", "NotificationOptionVideoPlayback": "Video gestart", "NotificationOptionVideoPlaybackStopped": "Video gestopt", "Photos": "Foto's", @@ -76,7 +75,6 @@ "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden, probeer het later opnieuw.", "SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt", "SubtitleDownloadFailureFromForItem": "Ondertitels konden niet gedownload worden van {0} voor {1}", - "SubtitlesDownloadedForItem": "Ondertiteling voor {0} is gedownload", "Sync": "Synchronisatie", "System": "Systeem", "TvShows": "TV-series", @@ -89,9 +87,38 @@ "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}" + "VersionNumber": "Versie {0}", + "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": "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.", + "TaskUpdatePlugins": "Update Plugins", + "TaskRefreshPeopleDescription": "Update metadata for acteurs en regisseurs in de media bibliotheek.", + "TaskRefreshPeople": "Vernieuw Personen", + "TaskCleanLogsDescription": "Verwijdert log bestanden ouder dan {0} dagen.", + "TaskRefreshLibraryDescription": "Scant de media bibliotheek voor nieuwe bestanden en vernieuwt de metadata.", + "TaskRefreshLibrary": "Scan Media Bibliotheek", + "TaskRefreshChapterImagesDescription": "Maakt thumbnails aan voor videos met hoofdstukken.", + "TaskRefreshChapterImages": "Hoofdstukafbeeldingen Uitpakken", + "TaskCleanCacheDescription": "Verwijdert gecachte bestanden die het systeem niet langer nodig heeft.", + "TaskCleanCache": "Cache Folder Opschonen", + "TasksChannelsCategory": "Internet Kanalen", + "TasksApplicationCategory": "Applicatie", + "TasksLibraryCategory": "Bibliotheek", + "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 new file mode 100644 index 000000000..32d4f3a8b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -0,0 +1,121 @@ +{ + "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", + "Latest": "Nyaste", + "LabelRunningTimeValue": "Speletid: {0}", + "LabelIpAddressValue": "IP-adresse: {0}", + "ItemRemovedWithName": "{0} vart fjerna frå biblioteket", + "ItemAddedWithName": "{0} vart lagt til i biblioteket", + "Inherit": "Arve", + "HomeVideos": "Heimevideoar", + "HeaderRecordingGroups": "Innspelingsgrupper", + "HeaderNextUp": "Neste", + "HeaderLiveTV": "Direkte TV", + "HeaderFavoriteSongs": "Favorittsongar", + "HeaderFavoriteShows": "Favorittseriar", + "HeaderFavoriteEpisodes": "Favorittepisodar", + "HeaderFavoriteArtists": "Favorittartistar", + "HeaderFavoriteAlbums": "Favorittalbum", + "HeaderContinueWatching": "Fortsett å sjå", + "HeaderAlbumArtists": "Albumartist", + "Genres": "Sjangrar", + "Folders": "Mapper", + "Favorites": "Favorittar", + "FailedLoginAttemptWithUserName": "Mislukka påloggingsforsøk frå {0}", + "DeviceOnlineWithName": "{0} er tilkopla", + "DeviceOfflineWithName": "{0} har kopla frå", + "Collections": "Samlingar", + "ChapterNameValue": "Kapittel {0}", + "Channels": "Kanalar", + "CameraImageUploadedFrom": "Eit nytt kamerabilete har blitt lasta opp frå {0}", + "Books": "Bøker", + "AuthenticationSucceededWithUserName": "{0} har logga inn", + "Artists": "Artistar", + "Application": "Program", + "AppDeviceValues": "App: {0}, Eining: {1}", + "Albums": "Album", + "NotificationOptionServerRestartRequired": "Tenaren krev omstart", + "NotificationOptionPluginUpdateInstalled": "Programvaretilleggoppdatering vart installert", + "NotificationOptionPluginUninstalled": "Programvaretillegg avinstallert", + "NotificationOptionPluginInstalled": "Programvaretillegg installert", + "NotificationOptionPluginError": "Programvaretillegg feila", + "NotificationOptionNewLibraryContent": "Nytt innhald er lagt til", + "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-tjenaren er tilgjengeleg for nedlasting.", + "NameSeasonUnknown": "Ukjend sesong", + "NameSeasonNumber": "Sesong {0}", + "NameInstallFailed": "Installasjonen av {0} feila", + "MusicVideos": "Musikkvideoar", + "Music": "Musikk", + "Movies": "Filmar", + "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 92b12409d..e8a32a13e 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -15,18 +15,17 @@ "Favorites": "Ulubione", "Folders": "Foldery", "Genres": "Gatunki", - "HeaderAlbumArtists": "Wykonawcy albumów", - "HeaderCameraUploads": "Przekazane obrazy", + "HeaderAlbumArtists": "Album artysty", "HeaderContinueWatching": "Kontynuuj odtwarzanie", - "HeaderFavoriteAlbums": "Albumy ulubione", - "HeaderFavoriteArtists": "Wykonawcy ulubieni", - "HeaderFavoriteEpisodes": "Odcinki ulubione", - "HeaderFavoriteShows": "Seriale ulubione", - "HeaderFavoriteSongs": "Utwory ulubione", + "HeaderFavoriteAlbums": "Ulubione albumy", + "HeaderFavoriteArtists": "Ulubieni wykonawcy", + "HeaderFavoriteEpisodes": "Ulubione odcinki", + "HeaderFavoriteShows": "Ulubione seriale", + "HeaderFavoriteSongs": "Ulubione utwory", "HeaderLiveTV": "Telewizja", "HeaderNextUp": "Do obejrzenia", "HeaderRecordingGroups": "Grupy nagrań", - "HomeVideos": "Nagrania prywatne", + "HomeVideos": "Nagrania domowe", "Inherit": "Dziedzicz", "ItemAddedWithName": "{0} zostało dodane do biblioteki", "ItemRemovedWithName": "{0} zostało usunięte z biblioteki", @@ -41,26 +40,26 @@ "Movies": "Filmy", "Music": "Muzyka", "MusicVideos": "Teledyski", - "NameInstallFailed": "Instalacja {0} nieudana.", + "NameInstallFailed": "Instalacja {0} nieudana", "NameSeasonNumber": "Sezon {0}", - "NameSeasonUnknown": "Sezon nieznany", + "NameSeasonUnknown": "Nieznany sezon", "NewVersionIsAvailable": "Nowa wersja serwera Jellyfin jest dostępna do pobrania.", "NotificationOptionApplicationUpdateAvailable": "Dostępna aktualizacja aplikacji", - "NotificationOptionApplicationUpdateInstalled": "Zainstalowano aktualizację aplikacji", + "NotificationOptionApplicationUpdateInstalled": "Zaktualizowano aplikację", "NotificationOptionAudioPlayback": "Rozpoczęto odtwarzanie muzyki", "NotificationOptionAudioPlaybackStopped": "Odtwarzane dźwięku zatrzymane", - "NotificationOptionCameraImageUploaded": "Przekazano obraz z urządzenia mobilnego", - "NotificationOptionInstallationFailed": "Niepowodzenie instalacji", + "NotificationOptionCameraImageUploaded": "Przekazano obraz z urządzenia przenośnego", + "NotificationOptionInstallationFailed": "Nieudana instalacja", "NotificationOptionNewLibraryContent": "Dodano nową zawartość", "NotificationOptionPluginError": "Awaria wtyczki", "NotificationOptionPluginInstalled": "Zainstalowano wtyczkę", "NotificationOptionPluginUninstalled": "Odinstalowano wtyczkę", - "NotificationOptionPluginUpdateInstalled": "Zainstalowano aktualizację wtyczki", + "NotificationOptionPluginUpdateInstalled": "Zaktualizowano wtyczkę", "NotificationOptionServerRestartRequired": "Wymagane ponowne uruchomienie serwera", "NotificationOptionTaskFailed": "Awaria zaplanowanego zadania", "NotificationOptionUserLockedOut": "Użytkownik zablokowany", "NotificationOptionVideoPlayback": "Rozpoczęto odtwarzanie wideo", - "NotificationOptionVideoPlaybackStopped": "Odtwarzanie wideo zatrzymane", + "NotificationOptionVideoPlaybackStopped": "Zatrzymano odtwarzanie wideo", "Photos": "Zdjęcia", "Playlists": "Listy odtwarzania", "Plugin": "Wtyczka", @@ -73,10 +72,9 @@ "ServerNameNeedsToBeRestarted": "{0} wymaga ponownego uruchomienia", "Shows": "Seriale", "Songs": "Utwory", - "StartupEmbyServerIsLoading": "Twa wczytywanie serwera Jellyfin. Spróbuj ponownie za chwilę.", + "StartupEmbyServerIsLoading": "Trwa wczytywanie serwera Jellyfin. Spróbuj ponownie za chwilę.", "SubtitleDownloadFailureForItem": "Pobieranie napisów dla {0} zakończone niepowodzeniem", "SubtitleDownloadFailureFromForItem": "Nieudane pobieranie napisów z {0} dla {1}", - "SubtitlesDownloadedForItem": "Pobrano napisy dla {0}", "Sync": "Synchronizacja", "System": "System", "TvShows": "Seriale", @@ -91,7 +89,36 @@ "UserPolicyUpdatedWithName": "Zmieniono zasady użytkowania dla {0}", "UserStartedPlayingItemWithValues": "{0} odtwarza {1} na {2}", "UserStoppedPlayingItemWithValues": "{0} zakończył odtwarzanie {1} na {2}", - "ValueHasBeenAddedToLibrary": "{0} został dodany to biblioteki mediów", + "ValueHasBeenAddedToLibrary": "{0} został dodany do biblioteki mediów", "ValueSpecialEpisodeName": "Specjalne - {0}", - "VersionNumber": "Wersja {0}" + "VersionNumber": "Wersja {0}", + "TaskDownloadMissingSubtitlesDescription": "Przeszukuje internet w poszukiwaniu brakujących napisów w oparciu o konfigurację metadanych.", + "TaskDownloadMissingSubtitles": "Pobierz brakujące napisy", + "TaskRefreshChannelsDescription": "Odświeża informacje o kanałach internetowych.", + "TaskRefreshChannels": "Odśwież kanały", + "TaskCleanTranscodeDescription": "Usuwa transkodowane pliki starsze niż 1 dzień.", + "TaskCleanTranscode": "Wyczyść folder transkodowania", + "TaskUpdatePluginsDescription": "Pobiera i instaluje aktualizacje dla pluginów które są skonfigurowane do automatycznej aktualizacji.", + "TaskUpdatePlugins": "Aktualizuj pluginy", + "TaskRefreshPeopleDescription": "Odświeża metadane o aktorów i reżyserów w Twojej bibliotece mediów.", + "TaskRefreshPeople": "Odśwież obsadę", + "TaskCleanLogsDescription": "Kasuje pliki logów starsze niż {0} dni.", + "TaskCleanLogs": "Wyczyść folder logów", + "TaskRefreshLibraryDescription": "Skanuje Twoją bibliotekę mediów dla nowych plików i odświeżenia metadanych.", + "TaskRefreshLibrary": "Skanuj bibliotekę mediów", + "TaskRefreshChapterImagesDescription": "Tworzy miniatury dla filmów posiadających rozdziały.", + "TaskRefreshChapterImages": "Wydobądź grafiki rozdziałów", + "TaskCleanCacheDescription": "Usuwa niepotrzebne i przestarzałe pliki cache.", + "TaskCleanCache": "Wyczyść folder Cache", + "TasksChannelsCategory": "Kanały internetowe", + "TasksApplicationCategory": "Aplikacja", + "TasksLibraryCategory": "Biblioteka", + "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", + "TaskOptimizeDatabaseDescription": "Kompaktuje bazę danych i obcina wolne miejsce. Uruchomienie tego zadania po przeskanowaniu biblioteki lub dokonaniu innych zmian, które pociągają za sobą modyfikacje bazy danych, może poprawić wydajność." } diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json new file mode 100644 index 000000000..e3a3bfaf1 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -0,0 +1,5 @@ +{ + "Books": "Libros", + "AuthenticationSucceededWithUserName": "{0} autentificado correctamente", + "Artists": "Artistas" +} diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index aaedf0850..be71289b1 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -5,51 +5,50 @@ "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "Books": "Livros", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "Uma nova imagem da câmera foi enviada de {0}", "Channels": "Canais", "ChapterNameValue": "Capítulo {0}", "Collections": "Coletâneas", "DeviceOfflineWithName": "{0} se desconectou", - "DeviceOnlineWithName": "{0} está conectado", + "DeviceOnlineWithName": "{0} se conectou", "FailedLoginAttemptWithUserName": "Falha na tentativa de login de {0}", "Favorites": "Favoritos", "Folders": "Pastas", "Genres": "Gêneros", "HeaderAlbumArtists": "Artistas do Álbum", - "HeaderCameraUploads": "Uploads da Câmera", - "HeaderContinueWatching": "Continuar Assistindo", + "HeaderContinueWatching": "Continuar assistindo", "HeaderFavoriteAlbums": "Álbuns Favoritos", - "HeaderFavoriteArtists": "Artistas Favoritos", - "HeaderFavoriteEpisodes": "Episódios Favoritos", - "HeaderFavoriteShows": "Séries Favoritas", - "HeaderFavoriteSongs": "Músicas Favoritas", + "HeaderFavoriteArtists": "Artistas favoritos", + "HeaderFavoriteEpisodes": "Episódios favoritos", + "HeaderFavoriteShows": "Séries favoritas", + "HeaderFavoriteSongs": "Músicas favoritas", "HeaderLiveTV": "TV ao Vivo", - "HeaderNextUp": "Próximos", + "HeaderNextUp": "A Seguir", "HeaderRecordingGroups": "Grupos de Gravação", "HomeVideos": "Vídeos caseiros", "Inherit": "Herdar", "ItemAddedWithName": "{0} foi adicionado à biblioteca", "ItemRemovedWithName": "{0} foi removido da biblioteca", - "LabelIpAddressValue": "Endereço ip: {0}", + "LabelIpAddressValue": "Endereço IP: {0}", "LabelRunningTimeValue": "Tempo de execução: {0}", - "Latest": "Recente", - "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "Latest": "Recentes", + "MessageApplicationUpdated": "Servidor Jellyfin atualizado", + "MessageApplicationUpdatedTo": "Servidor Jellyfin atualizado para {0}", "MessageNamedServerConfigurationUpdatedWithValue": "A seção {0} da configuração do servidor foi atualizada", "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", "MixedContent": "Conteúdo misto", "Movies": "Filmes", "Music": "Música", - "MusicVideos": "Vídeos musicais", - "NameInstallFailed": "{0} installation failed", + "MusicVideos": "Videoclipes", + "NameInstallFailed": "A instalação de {0} falhou", "NameSeasonNumber": "Temporada {0}", "NameSeasonUnknown": "Temporada Desconhecida", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicativo disponível", - "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicativo instalada", + "NewVersionIsAvailable": "Uma nova versão do Servidor Jellyfin está disponível para download.", + "NotificationOptionApplicationUpdateAvailable": "Atualização do aplicativo disponível", + "NotificationOptionApplicationUpdateInstalled": "Atualização do aplicativo instalada", "NotificationOptionAudioPlayback": "Reprodução de áudio iniciada", "NotificationOptionAudioPlaybackStopped": "Reprodução de áudio parada", - "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada", + "NotificationOptionCameraImageUploaded": "Imagem da câmera enviada", "NotificationOptionInstallationFailed": "Falha na instalação", "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", "NotificationOptionPluginError": "Falha de plugin", @@ -70,28 +69,56 @@ "ProviderValue": "Provedor: {0}", "ScheduledTaskFailedWithName": "{0} falhou", "ScheduledTaskStartedWithName": "{0} iniciada", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", + "ServerNameNeedsToBeRestarted": "O servidor {0} precisa ser reiniciado", "Shows": "Séries", "Songs": "Músicas", - "StartupEmbyServerIsLoading": "O Servidor Jellyfin está carregando. Por favor tente novamente em breve.", + "StartupEmbyServerIsLoading": "O Servidor Jellyfin está carregando. Por favor, tente novamente mais tarde.", "SubtitleDownloadFailureForItem": "Download de legendas falhou para {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Legendas baixadas para {0}", + "SubtitleDownloadFailureFromForItem": "Houve um problema ao baixar as legendas de {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", - "TvShows": "Séries de TV", + "TvShows": "Séries", "User": "Usuário", "UserCreatedWithName": "O usuário {0} foi criado", "UserDeletedWithName": "O usuário {0} foi excluído", "UserDownloadingItemWithValues": "{0} está baixando {1}", "UserLockedOutWithName": "Usuário {0} foi bloqueado", "UserOfflineFromDevice": "{0} se desconectou de {1}", - "UserOnlineFromDevice": "{0} está ativo em {1}", + "UserOnlineFromDevice": "{0} está online em {1}", "UserPasswordChangedWithName": "A senha foi alterada para o usuário {0}", "UserPolicyUpdatedWithName": "A política de usuário foi atualizada para {0}", - "UserStartedPlayingItemWithValues": "{0} iniciou a reprodução de {1}", - "UserStoppedPlayingItemWithValues": "{0} parou de reproduzir {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", + "UserStoppedPlayingItemWithValues": "{0} parou de reproduzir {1} em {2}", + "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua biblioteca de mídia", "ValueSpecialEpisodeName": "Especial - {0}", - "VersionNumber": "Versão {0}" + "VersionNumber": "Versão {0}", + "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas faltando baseado na configuração de metadados.", + "TaskDownloadMissingSubtitles": "Baixar legendas que estão faltando", + "TaskRefreshChannelsDescription": "Atualizar informação de canais da internet .", + "TaskRefreshChannels": "Atualizar Canais", + "TaskCleanTranscodeDescription": "Deletar arquivos de transcodificação com mais de um dia de criação.", + "TaskCleanTranscode": "Limpar pasta de transcodificação", + "TaskUpdatePluginsDescription": "Baixa e instala atualizações para plugins que estão configurados para atualizar automaticamente.", + "TaskUpdatePlugins": "Atualizar Plugins", + "TaskRefreshPeopleDescription": "Atualiza metadados para atores e diretores na sua biblioteca de mídia.", + "TaskRefreshPeople": "Atualizar pessoas", + "TaskCleanLogsDescription": "Deletar arquivos temporários com mais de {0} dias.", + "TaskCleanLogs": "Limpar pasta de logs", + "TaskRefreshLibraryDescription": "Escaneie a sua biblioteca de mídia para arquivos novos e atualize os metadados.", + "TaskRefreshLibrary": "Escanear a Biblioteca de Mídia", + "TaskRefreshChapterImagesDescription": "Criar miniaturas para vídeos que tem capítulos.", + "TaskRefreshChapterImages": "Extrair imagens dos capítulos", + "TaskCleanCacheDescription": "Deletar arquivos temporários que não são mais necessários para o sistema.", + "TaskCleanCache": "Limpar Arquivos Temporários", + "TasksChannelsCategory": "Canais da Internet", + "TasksApplicationCategory": "Aplicativo", + "TasksLibraryCategory": "Biblioteca", + "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", + "TaskOptimizeDatabaseDescription": "Compactar base de dados e liberar espaço livre. Executar esta tarefa após realizar mudanças que impliquem em modificações da base de dados pode trazer melhorias de desempenho.", + "TaskOptimizeDatabase": "Otimizar base de dados" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index dc69d8af2..8c41edf96 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -1,97 +1,122 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", + "Albums": "Álbuns", + "AppDeviceValues": "Aplicação {0}, Dispositivo: {1}", "Application": "Aplicação", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continuar a ver", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", + "Artists": "Artistas", + "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", + "Books": "Livros", + "CameraImageUploadedFrom": "Uma nova imagem de câmara foi enviada a partir de {0}", + "Channels": "Canais", + "ChapterNameValue": "Capítulo {0}", + "Collections": "Coleções", + "DeviceOfflineWithName": "{0} desligou-se", + "DeviceOnlineWithName": "{0} ligou-se", + "FailedLoginAttemptWithUserName": "Tentativa de login falhada a partir de {0}", + "Favorites": "Favoritos", + "Folders": "Pastas", + "Genres": "Géneros", + "HeaderAlbumArtists": "Artistas do Álbum", + "HeaderContinueWatching": "Continuar a Ver", + "HeaderFavoriteAlbums": "Álbuns Favoritos", + "HeaderFavoriteArtists": "Artistas Favoritos", + "HeaderFavoriteEpisodes": "Episódios Favoritos", "HeaderFavoriteShows": "Séries Favoritas", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", + "HeaderFavoriteSongs": "Músicas Favoritas", + "HeaderLiveTV": "TV em Direto", + "HeaderNextUp": "A Seguir", + "HeaderRecordingGroups": "Grupos de Gravação", + "HomeVideos": "Vídeos Caseiros", + "Inherit": "Herdar", + "ItemAddedWithName": "{0} foi adicionado à biblioteca", + "ItemRemovedWithName": "{0} foi removido da biblioteca", + "LabelIpAddressValue": "Endereço IP: {0}", + "LabelRunningTimeValue": "Duração: {0}", + "Latest": "Mais Recente", + "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", + "MessageApplicationUpdatedTo": "O servidor Jellyfin foi atualizado para a versão {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Configurações de servidor na secção {0} foram atualizadas", + "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", + "MixedContent": "Conteúdo Misto", + "Movies": "Filmes", + "Music": "Música", + "MusicVideos": "Videoclips", + "NameInstallFailed": "{0} falha na instalação", + "NameSeasonNumber": "Temporada {0}", + "NameSeasonUnknown": "Temporada Desconhecida", + "NewVersionIsAvailable": "Está disponível para transferência uma nova versão do servidor Jellyfin.", + "NotificationOptionApplicationUpdateAvailable": "Atualização de aplicação disponível", + "NotificationOptionApplicationUpdateInstalled": "Atualização de aplicação instalada", + "NotificationOptionAudioPlayback": "Reprodução Iniciada", + "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", + "NotificationOptionCameraImageUploaded": "Imagem da câmara enviada", + "NotificationOptionInstallationFailed": "Falha na instalação", + "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", + "NotificationOptionPluginError": "Falha na extensão", + "NotificationOptionPluginInstalled": "Extensão instalada", + "NotificationOptionPluginUninstalled": "Extensão desinstalada", + "NotificationOptionPluginUpdateInstalled": "Extensão atualizada", + "NotificationOptionServerRestartRequired": "Necessário reiniciar o servidor", + "NotificationOptionTaskFailed": "Falha em tarefa agendada", + "NotificationOptionUserLockedOut": "Utilizador bloqueado", + "NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada", + "NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada", + "Photos": "Fotografias", + "Playlists": "Listas de Reprodução", + "Plugin": "Extensão", + "PluginInstalledWithName": "{0} foi instalado", + "PluginUninstalledWithName": "{0} foi desinstalado", + "PluginUpdatedWithName": "{0} foi atualizado", "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "ScheduledTaskFailedWithName": "{0} falhou", + "ScheduledTaskStartedWithName": "{0} iniciou", + "ServerNameNeedsToBeRestarted": "{0} necessita de ser reiniciado", + "Shows": "Séries", + "Songs": "Músicas", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", - "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "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": "Especial - {0}", - "VersionNumber": "Version {0}" + "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}", + "Sync": "Sincronização", + "System": "Sistema", + "TvShows": "Programas TV", + "User": "Utilizador", + "UserCreatedWithName": "Utilizador {0} criado", + "UserDeletedWithName": "Utilizador {0} apagado", + "UserDownloadingItemWithValues": "{0} está a transferir {1}", + "UserLockedOutWithName": "Utilizador {0} bloqueado", + "UserOfflineFromDevice": "{0} desligou-se a partir de {1}", + "UserOnlineFromDevice": "{0} ligou-se a partir de {1}", + "UserPasswordChangedWithName": "Palavra-passe alterada para o utilizador {0}", + "UserPolicyUpdatedWithName": "Política de utilizador alterada para {0}", + "UserStartedPlayingItemWithValues": "{0} está a reproduzir {1} em {2}", + "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", + "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua biblioteca multimédia", + "ValueSpecialEpisodeName": "Especial - {0}", + "VersionNumber": "Versão {0}", + "TaskDownloadMissingSubtitlesDescription": "Procurar na internet por legendas em falta baseado na configuração de metadados.", + "TaskDownloadMissingSubtitles": "Fazer download de legendas em falta", + "TaskRefreshChannelsDescription": "Atualizar informação sobre canais da Internet.", + "TaskRefreshChannels": "Atualizar Canais", + "TaskCleanTranscodeDescription": "Apagar ficheiros de transcode com mais de um dia.", + "TaskCleanTranscode": "Limpar a Diretoria de Transcode", + "TaskUpdatePluginsDescription": "Faz o download e instala updates para os plugins que estão configurados para atualizar automaticamente.", + "TaskUpdatePlugins": "Atualizar Plugins", + "TaskRefreshPeopleDescription": "Atualizar metadados para atores e diretores na biblioteca.", + "TaskRefreshPeople": "Atualizar Pessoas", + "TaskCleanLogsDescription": "Apagar ficheiros de log que têm mais de {0} dias.", + "TaskCleanLogs": "Limpar a Diretoria de Logs", + "TaskRefreshLibraryDescription": "Scannear a biblioteca de música para novos ficheiros e atualizar os metadados.", + "TaskRefreshLibrary": "Scannear Biblioteca de Música", + "TaskRefreshChapterImagesDescription": "Criar thumbnails para os vídeos que têm capítulos.", + "TaskRefreshChapterImages": "Extrair Imagens dos Capítulos", + "TaskCleanCacheDescription": "Apagar ficheiros em cache que já não são necessários.", + "TaskCleanCache": "Limpar Cache", + "TasksChannelsCategory": "Canais da Internet", + "TasksApplicationCategory": "Aplicação", + "TasksLibraryCategory": "Biblioteca", + "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 new file mode 100644 index 000000000..a9dbd53ea --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -0,0 +1,123 @@ +{ + "HeaderLiveTV": "TV Ao Vivo", + "Collections": "Coleções", + "Books": "Livros", + "Artists": "Artistas", + "Albums": "Álbuns", + "HeaderNextUp": "A Seguir", + "HeaderFavoriteSongs": "Músicas Favoritas", + "HeaderFavoriteArtists": "Artistas Favoritos", + "HeaderFavoriteAlbums": "Álbuns Favoritos", + "HeaderFavoriteEpisodes": "Episódios Favoritos", + "HeaderFavoriteShows": "Séries Favoritas", + "HeaderContinueWatching": "Continuar assistindo", + "HeaderAlbumArtists": "Artistas do Álbum", + "Genres": "Gêneros", + "Folders": "Diretórios", + "Favorites": "Favoritos", + "Channels": "Canais", + "UserDownloadingItemWithValues": "{0} está sendo baixado {1}", + "VersionNumber": "Versão {0}", + "ValueHasBeenAddedToLibrary": "{0} foi adicionado à sua biblioteca multimédia", + "UserStoppedPlayingItemWithValues": "{0} terminou a reprodução de {1} em {2}", + "UserStartedPlayingItemWithValues": "{0} está reproduzindo {1} em {2}", + "UserPolicyUpdatedWithName": "A política do usuário {0} foi alterada", + "UserPasswordChangedWithName": "A senha do usuário {0} foi alterada", + "UserOnlineFromDevice": "{0} está online a partir de {1}", + "UserOfflineFromDevice": "{0} desconectou-se a partir de {1}", + "UserLockedOutWithName": "O usuário {0} foi bloqueado", + "UserDeletedWithName": "O usuário {0} foi removido", + "UserCreatedWithName": "O usuário {0} foi criado", + "User": "Usuário", + "TvShows": "Séries", + "System": "Sistema", + "SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas de {0} para {1}", + "StartupEmbyServerIsLoading": "O servidor Jellyfin está iniciando. Tente novamente dentro de momentos.", + "ServerNameNeedsToBeRestarted": "{0} necessita ser reiniciado", + "ScheduledTaskStartedWithName": "{0} iniciou", + "ScheduledTaskFailedWithName": "{0} falhou", + "ProviderValue": "Fornecedor: {0}", + "PluginUpdatedWithName": "{0} foi atualizado", + "PluginUninstalledWithName": "{0} foi desinstalado", + "PluginInstalledWithName": "{0} foi instalado", + "Plugin": "Plugin", + "NotificationOptionVideoPlaybackStopped": "Reprodução de vídeo parada", + "NotificationOptionVideoPlayback": "Reprodução de vídeo iniciada", + "NotificationOptionUserLockedOut": "Usuário bloqueado", + "NotificationOptionTaskFailed": "Falha na tarefa agendada", + "NotificationOptionServerRestartRequired": "É necessário reiniciar o servidor", + "NotificationOptionPluginUpdateInstalled": "Plugin atualizado", + "NotificationOptionPluginUninstalled": "Plugin desinstalado", + "NotificationOptionPluginInstalled": "Plugin instalado", + "NotificationOptionPluginError": "Falha no plugin", + "NotificationOptionNewLibraryContent": "Novo conteúdo adicionado", + "NotificationOptionInstallationFailed": "Falha de instalação", + "NotificationOptionCameraImageUploaded": "Imagem de câmera enviada", + "NotificationOptionAudioPlaybackStopped": "Reprodução Parada", + "NotificationOptionAudioPlayback": "Reprodução Iniciada", + "NotificationOptionApplicationUpdateInstalled": "A atualização do aplicativo foi instalada", + "NotificationOptionApplicationUpdateAvailable": "Uma atualização do aplicativo está disponível", + "NewVersionIsAvailable": "Uma nova versão do servidor Jellyfin está disponível para download.", + "NameSeasonUnknown": "Temporada Desconhecida", + "NameSeasonNumber": "Temporada {0}", + "NameInstallFailed": "Falha na instalação de {0}", + "MusicVideos": "Videoclipes", + "Music": "Música", + "MixedContent": "Conteúdo diverso", + "MessageServerConfigurationUpdated": "A configuração do servidor foi atualizada", + "MessageNamedServerConfigurationUpdatedWithValue": "As configurações do servidor na seção {0} foram atualizadas", + "MessageApplicationUpdatedTo": "O servidor Jellyfin foi atualizado para a versão {0}", + "MessageApplicationUpdated": "O servidor Jellyfin foi atualizado", + "Latest": "Mais Recente", + "LabelRunningTimeValue": "Duração: {0}", + "LabelIpAddressValue": "Endereço de IP: {0}", + "ItemRemovedWithName": "{0} foi removido da biblioteca", + "ItemAddedWithName": "{0} foi adicionado à biblioteca", + "Inherit": "Herdar", + "HomeVideos": "Vídeos principais", + "HeaderRecordingGroups": "Grupos de Gravação", + "ValueSpecialEpisodeName": "Episódio Especial - {0}", + "Sync": "Sincronização", + "Songs": "Músicas", + "Shows": "Séries", + "Playlists": "Listas de Reprodução", + "Photos": "Fotografias", + "Movies": "Filmes", + "FailedLoginAttemptWithUserName": "Tentativa falha de login a partir de {0}", + "DeviceOnlineWithName": "{0} está conectado", + "DeviceOfflineWithName": "{0} desconectou-se", + "ChapterNameValue": "Capítulo {0}", + "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}", + "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", + "Application": "Aplicativo", + "AppDeviceValues": "Aplicativo {0}, Dispositivo: {1}", + "TaskCleanCache": "Limpar Diretório de Cache", + "TasksApplicationCategory": "Aplicativo", + "TasksLibraryCategory": "Biblioteca", + "TasksMaintenanceCategory": "Manutenção", + "TaskRefreshChannels": "Atualizar Canais", + "TaskUpdatePlugins": "Atualizar Plugins", + "TaskCleanLogsDescription": "Deletar arquivos de log que existe a mais de {0} dias.", + "TaskCleanLogs": "Limpar diretório de logs", + "TaskRefreshLibrary": "Escanear biblioteca de mídias", + "TaskRefreshChapterImagesDescription": "Cria miniaturas para vídeos que têm capítulos.", + "TaskCleanCacheDescription": "Apaga ficheiros em cache que já não são usados pelo sistema.", + "TasksChannelsCategory": "Canais de Internet", + "TaskRefreshChapterImages": "Extrair Imagens do Capítulo", + "TaskDownloadMissingSubtitlesDescription": "Pesquisa na Internet as legendas em falta com base na configuração de metadados.", + "TaskDownloadMissingSubtitles": "Download das legendas em falta", + "TaskRefreshChannelsDescription": "Atualiza as informações do canal da Internet.", + "TaskCleanTranscodeDescription": "Apagar os ficheiros com mais de um dia, de Transcode.", + "TaskCleanTranscode": "Limpar o diretório de Transcode", + "TaskUpdatePluginsDescription": "Baixa 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 sua biblioteca de media por novos arquivos e atualiza os metadados.", + "TaskCleanActivityLog": "Limpar registro de atividade", + "Undefined": "Indefinido", + "Forced": "Forçado", + "Default": "Predefinição", + "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.", + "TaskOptimizeDatabase": "Otimizar base de dados", + "TaskOptimizeDatabaseDescription": "Base de dados compacta e corta espaço livre. A execução desta tarefa depois de digitalizar a biblioteca ou de fazer outras alterações que impliquem modificações na base de dados pode melhorar o desempenho." +} diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json new file mode 100644 index 000000000..510aac11c --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -0,0 +1,121 @@ +{ + "HeaderNextUp": "Urmează", + "VersionNumber": "Versiunea {0}", + "ValueSpecialEpisodeName": "Special - {0}", + "ValueHasBeenAddedToLibrary": "{0} a fost adăugat la biblioteca multimedia", + "UserStoppedPlayingItemWithValues": "{0} a terminat rularea {1} pe {2}", + "UserStartedPlayingItemWithValues": "{0} ruleaza {1} pe {2}", + "UserPolicyUpdatedWithName": "Politica utilizatorului {0} a fost actualizată", + "UserPasswordChangedWithName": "Parola utilizatorului {0} a fost schimbată", + "UserOnlineFromDevice": "{0} este conectat de la {1}", + "UserOfflineFromDevice": "{0} s-a deconectat de la {1}", + "UserLockedOutWithName": "Utilizatorul {0} a fost blocat", + "UserDownloadingItemWithValues": "{0} descarcă {1}", + "UserDeletedWithName": "Utilizatorul {0} a fost eliminat", + "UserCreatedWithName": "Utilizatorul {0} a fost creat", + "User": "Utilizator", + "TvShows": "Spectacole TV", + "System": "Sistem", + "Sync": "Sincronizare", + "SubtitleDownloadFailureFromForItem": "Subtitrările nu au putut fi descărcate de la {0} pentru {1}", + "StartupEmbyServerIsLoading": "Se încarcă serverul Jellyfin. Încercați din nou în scurt timp.", + "Songs": "Melodii", + "Shows": "Spectacole", + "ServerNameNeedsToBeRestarted": "{0} trebuie repornit", + "ScheduledTaskStartedWithName": "{0} pornit/ă", + "ScheduledTaskFailedWithName": "{0} eșuat/ă", + "ProviderValue": "Furnizor: {0}", + "PluginUpdatedWithName": "{0} a fost actualizat/ă", + "PluginUninstalledWithName": "{0} a fost dezinstalat", + "PluginInstalledWithName": "{0} a fost instalat", + "Plugin": "Complement", + "Playlists": "Liste redare", + "Photos": "Fotografii", + "NotificationOptionVideoPlaybackStopped": "Redarea video oprită", + "NotificationOptionVideoPlayback": "Începută redarea video", + "NotificationOptionUserLockedOut": "Utilizatorul a fost blocat", + "NotificationOptionTaskFailed": "Activitate programata eșuată", + "NotificationOptionServerRestartRequired": "Este necesară repornirea Serverului", + "NotificationOptionPluginUpdateInstalled": "Actualizare plugin instalată", + "NotificationOptionPluginUninstalled": "Plugin dezinstalat", + "NotificationOptionPluginInstalled": "Plugin instalat", + "NotificationOptionPluginError": "Plugin-ul a eșuat", + "NotificationOptionNewLibraryContent": "Adăugat conținut nou", + "NotificationOptionInstallationFailed": "Eșec la instalare", + "NotificationOptionCameraImageUploaded": "Încarcată imagine cameră", + "NotificationOptionAudioPlaybackStopped": "Redare audio oprită", + "NotificationOptionAudioPlayback": "A inceput redarea audio", + "NotificationOptionApplicationUpdateInstalled": "Actualizarea aplicației a fost instalată", + "NotificationOptionApplicationUpdateAvailable": "Disponibilă o actualizare a aplicației", + "NewVersionIsAvailable": "O nouă versiune a Jellyfin Server este disponibilă pentru descărcare.", + "NameSeasonUnknown": "Sezon Necunoscut", + "NameSeasonNumber": "Sezonul {0}", + "NameInstallFailed": "{0} instalare eșuată", + "MusicVideos": "Videoclipuri muzicale", + "Music": "Muzică", + "Movies": "Filme", + "MixedContent": "Conținut mixt", + "MessageServerConfigurationUpdated": "Configurația serverului a fost actualizată", + "MessageNamedServerConfigurationUpdatedWithValue": "Secțiunea de configurare a serverului {0} a fost acualizata", + "MessageApplicationUpdatedTo": "Jellyfin Server a fost actualizat la {0}", + "MessageApplicationUpdated": "Jellyfin Server a fost actualizat", + "Latest": "Cele mai recente", + "LabelRunningTimeValue": "Durată: {0}", + "LabelIpAddressValue": "Adresa IP: {0}", + "ItemRemovedWithName": "{0} a fost eliminat din bibliotecă", + "ItemAddedWithName": "{0} a fost adăugat în bibliotecă", + "Inherit": "Moștenit", + "HomeVideos": "Filme personale", + "HeaderRecordingGroups": "Grupuri de înregistrare", + "HeaderLiveTV": "TV în Direct", + "HeaderFavoriteSongs": "Melodii Favorite", + "HeaderFavoriteShows": "Spectacole Favorite", + "HeaderFavoriteEpisodes": "Episoade Favorite", + "HeaderFavoriteArtists": "Artiști Favoriți", + "HeaderFavoriteAlbums": "Albume Favorite", + "HeaderContinueWatching": "Vizionează în continuare", + "HeaderAlbumArtists": "Album Artiști", + "Genres": "Genuri", + "Folders": "Dosare", + "Favorites": "Favorite", + "FailedLoginAttemptWithUserName": "Încercare de conectare nereușită de la {0}", + "DeviceOnlineWithName": "{0} este conectat", + "DeviceOfflineWithName": "{0} s-a deconectat", + "Collections": "Colecții", + "ChapterNameValue": "Capitol {0}", + "Channels": "Canale", + "CameraImageUploadedFrom": "O nouă fotografie a fost încărcată din {0}", + "Books": "Cărți", + "AuthenticationSucceededWithUserName": "{0} autentificare reușită", + "Artists": "Artiști", + "Application": "Aplicație", + "AppDeviceValues": "Aplicație: {0}, Dispozitiv: {1}", + "Albums": "Albume", + "TaskDownloadMissingSubtitlesDescription": "Caută pe internet subtitrările lipsă pe baza configurației metadatelor.", + "TaskDownloadMissingSubtitles": "Descarcă subtitrările lipsă", + "TaskRefreshChannelsDescription": "Actualizează informațiile despre canalul de internet.", + "TaskRefreshChannels": "Actualizează canale", + "TaskCleanTranscodeDescription": "Șterge fișierele de transcodare mai vechi de o zi.", + "TaskCleanTranscode": "Curățați directorul de transcodare", + "TaskUpdatePluginsDescription": "Descarcă și instalează actualizări pentru pluginuri care sunt configurate să se actualizeze automat.", + "TaskUpdatePlugins": "Actualizați plugin-uri", + "TaskRefreshPeopleDescription": "Actualizează metadatele pentru actori și regizori din biblioteca media.", + "TaskRefreshPeople": "Actualizează oamenii", + "TaskCleanLogsDescription": "Șterge fișierele jurnal care au mai mult de {0} zile.", + "TaskCleanLogs": "Curățare director jurnal", + "TaskRefreshLibraryDescription": "Scanează biblioteca media pentru fișiere noi și reîmprospătează metadatele.", + "TaskRefreshLibrary": "Scanează Biblioteca Media", + "TaskRefreshChapterImagesDescription": "Creează miniaturi pentru videourile care au capitole.", + "TaskRefreshChapterImages": "Extrage Imaginile de Capitol", + "TaskCleanCacheDescription": "Șterge fișierele cache care nu mai sunt necesare sistemului.", + "TaskCleanCache": "Curățați directorul cache", + "TasksChannelsCategory": "Canale de pe Internet", + "TasksApplicationCategory": "Aplicație", + "TasksLibraryCategory": "Librărie", + "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 c0465def8..cd016b51b 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -1,42 +1,41 @@ { "Albums": "Альбомы", - "AppDeviceValues": "Прил.: {0}, Устр.: {1}", + "AppDeviceValues": "Приложение.: {0}, Устройство.: {1}", "Application": "Приложение", "Artists": "Исполнители", "AuthenticationSucceededWithUserName": "{0} - авторизация успешна", - "Books": "Литература", - "CameraImageUploadedFrom": "Новое фото было выложено с камеры {0}", + "Books": "Книги", + "CameraImageUploadedFrom": "Новое фото загружено с камеры {0}", "Channels": "Каналы", "ChapterNameValue": "Сцена {0}", "Collections": "Коллекции", - "DeviceOfflineWithName": "{0} - подкл. разъ-но", - "DeviceOnlineWithName": "{0} - подкл. уст-но", + "DeviceOfflineWithName": "{0} - отключено", + "DeviceOnlineWithName": "{0} - подключено", "FailedLoginAttemptWithUserName": "{0} - попытка входа неудачна", "Favorites": "Избранное", "Folders": "Папки", "Genres": "Жанры", - "HeaderAlbumArtists": "Исп-ли альбома", - "HeaderCameraUploads": "Камеры", + "HeaderAlbumArtists": "Исполнители альбома", "HeaderContinueWatching": "Продолжение просмотра", "HeaderFavoriteAlbums": "Избранные альбомы", "HeaderFavoriteArtists": "Избранные исполнители", "HeaderFavoriteEpisodes": "Избранные эпизоды", - "HeaderFavoriteShows": "Избранные передачи", + "HeaderFavoriteShows": "Избранные сериалы", "HeaderFavoriteSongs": "Избранные композиции", "HeaderLiveTV": "Эфир", "HeaderNextUp": "Очередное", "HeaderRecordingGroups": "Группы записей", - "HomeVideos": "Дом. видео", + "HomeVideos": "Домашние видео", "Inherit": "Наследуемое", "ItemAddedWithName": "{0} - добавлено в медиатеку", "ItemRemovedWithName": "{0} - изъято из медиатеки", "LabelIpAddressValue": "IP-адрес: {0}", "LabelRunningTimeValue": "Длительность: {0}", - "Latest": "Новейшее", + "Latest": "Последнее", "MessageApplicationUpdated": "Jellyfin Server был обновлён", "MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Конфиг-ия сервера (раздел {0}) была обновлена", - "MessageServerConfigurationUpdated": "Конфиг-ия сервера была обновлена", + "MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена", + "MessageServerConfigurationUpdated": "Конфигурация сервера была обновлена", "MixedContent": "Смешанное содержимое", "Movies": "Кино", "Music": "Музыка", @@ -47,9 +46,9 @@ "NewVersionIsAvailable": "Новая версия Jellyfin Server доступна для загрузки.", "NotificationOptionApplicationUpdateAvailable": "Имеется обновление приложения", "NotificationOptionApplicationUpdateInstalled": "Обновление приложения установлено", - "NotificationOptionAudioPlayback": "Воспр-ие аудио зап-но", - "NotificationOptionAudioPlaybackStopped": "Восп-ие аудио ост-но", - "NotificationOptionCameraImageUploaded": "Произведена выкладка отснятого с камеры", + "NotificationOptionAudioPlayback": "Воспроизведение аудио запущено", + "NotificationOptionAudioPlaybackStopped": "Воспроизведение аудио остановлено", + "NotificationOptionCameraImageUploaded": "Изображения с камеры загружены", "NotificationOptionInstallationFailed": "Сбой установки", "NotificationOptionNewLibraryContent": "Новое содержание добавлено", "NotificationOptionPluginError": "Сбой плагина", @@ -59,8 +58,8 @@ "NotificationOptionServerRestartRequired": "Требуется перезапуск сервера", "NotificationOptionTaskFailed": "Сбой назначенной задачи", "NotificationOptionUserLockedOut": "Пользователь заблокирован", - "NotificationOptionVideoPlayback": "Воспр-ие видео зап-но", - "NotificationOptionVideoPlaybackStopped": "Восп-ие видео ост-но", + "NotificationOptionVideoPlayback": "Воспроизведение видео запущено", + "NotificationOptionVideoPlaybackStopped": "Воспроизведение видео остановлено", "Photos": "Фото", "Playlists": "Плей-листы", "Plugin": "Плагин", @@ -76,22 +75,50 @@ "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", - "SubtitlesDownloadedForItem": "Субтитры к {0} загружены", "Sync": "Синхро", "System": "Система", "TvShows": "ТВ", - "User": "Польз-ль", + "User": "Пользователь", "UserCreatedWithName": "Пользователь {0} был создан", "UserDeletedWithName": "Пользователь {0} был удалён", "UserDownloadingItemWithValues": "{0} загружает {1}", "UserLockedOutWithName": "Пользователь {0} был заблокирован", - "UserOfflineFromDevice": "{0} - подкл. с {1} разъ-но", - "UserOnlineFromDevice": "{0} - подкл. с {1} уст-но", - "UserPasswordChangedWithName": "Пароль польз-ля {0} был изменён", - "UserPolicyUpdatedWithName": "Польз-ие политики {0} были обновлены", - "UserStartedPlayingItemWithValues": "{0} - воспр. «{1}» на {2}", - "UserStoppedPlayingItemWithValues": "{0} - воспр. «{1}» ост-но на {2}", - "ValueHasBeenAddedToLibrary": "{0} (добавлено в медиатеку)", + "UserOfflineFromDevice": "{0} отключился с {1}", + "UserOnlineFromDevice": "{0} подключился с {1}", + "UserPasswordChangedWithName": "Пароль пользователя {0} был изменён", + "UserPolicyUpdatedWithName": "Политики пользователя {0} были обновлены", + "UserStartedPlayingItemWithValues": "{0} - воспроизведение «{1}» на {2}", + "UserStoppedPlayingItemWithValues": "{0} - воспроизведение остановлено «{1}» на {2}", + "ValueHasBeenAddedToLibrary": "{0} добавлено в медиатеку", "ValueSpecialEpisodeName": "Спецэпизод - {0}", - "VersionNumber": "Версия {0}" + "VersionNumber": "Версия {0}", + "TaskDownloadMissingSubtitles": "Загрузка отсутствующих субтитров", + "TaskRefreshChannels": "Обновление каналов", + "TaskCleanTranscode": "Очистка каталога перекодировки", + "TaskUpdatePlugins": "Обновление плагинов", + "TaskRefreshPeople": "Подновить людей", + "TaskCleanLogs": "Очистка каталога журналов", + "TaskRefreshLibrary": "Сканирование медиатеки", + "TaskRefreshChapterImages": "Извлечение изображений сцен", + "TaskCleanCache": "Очистка каталога кеша", + "TasksChannelsCategory": "Интернет-каналы", + "TasksApplicationCategory": "Приложение", + "TasksLibraryCategory": "Медиатека", + "TasksMaintenanceCategory": "Обслуживание", + "TaskDownloadMissingSubtitlesDescription": "Выполняется поиск в Интернете отсутствующих субтитров на основе конфигурации метаданных.", + "TaskRefreshChannelsDescription": "Обновляются данные интернет-каналов.", + "TaskCleanTranscodeDescription": "Удаляются файлы перекодировки старше одного дня.", + "TaskUpdatePluginsDescription": "Загружаются и устанавливаются обновления для плагинов, у которых включено автоматическое обновление.", + "TaskRefreshPeopleDescription": "Обновляются метаданные для актёров и режиссёров в медиатеке.", + "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).", + "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.", + "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.", + "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 bc7e7184e..ad90bd813 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -2,30 +2,29 @@ "Albums": "Albumy", "AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}", "Application": "Aplikácia", - "Artists": "Umelci", + "Artists": "Interpreti", "AuthenticationSucceededWithUserName": "{0} úspešne overený", "Books": "Knihy", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia", "Channels": "Kanály", "ChapterNameValue": "Kapitola {0}", - "Collections": "Zbierky", - "DeviceOfflineWithName": "{0} je odpojený", + "Collections": "Kolekcie", + "DeviceOfflineWithName": "{0} sa odpojil", "DeviceOnlineWithName": "{0} je pripojený", "FailedLoginAttemptWithUserName": "Neúspešný pokus o prihlásenie z {0}", "Favorites": "Obľúbené", "Folders": "Priečinky", "Genres": "Žánre", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Pokračujte v pozeraní", + "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é pesničky", + "HeaderFavoriteSongs": "Obľúbené skladby", "HeaderLiveTV": "Živá TV", "HeaderNextUp": "Nasleduje", - "HeaderRecordingGroups": "Recording Groups", + "HeaderRecordingGroups": "Skupiny nahrávok", "HomeVideos": "Domáce videá", "Inherit": "Zdediť", "ItemAddedWithName": "{0} bol pridaný do knižnice", @@ -34,64 +33,92 @@ "LabelRunningTimeValue": "Dĺžka: {0}", "Latest": "Najnovšie", "MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizovaný na verziu {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná", - "MessageServerConfigurationUpdated": "Konfigurácia servera aktualizovaná", + "MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná", "MixedContent": "Zmiešaný obsah", "Movies": "Filmy", "Music": "Hudba", "MusicVideos": "Hudobné videá", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Sezóna {0}", - "NameSeasonUnknown": "Neznáma sezóna", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NameInstallFailed": "Inštalácia {0} zlyhala", + "NameSeasonNumber": "Séria {0}", + "NameSeasonUnknown": "Neznáma séria", + "NewVersionIsAvailable": "Nová verzia Jellyfin Serveru je dostupná na stiahnutie.", "NotificationOptionApplicationUpdateAvailable": "Je dostupná aktualizácia aplikácie", - "NotificationOptionApplicationUpdateInstalled": "Aktualizácia aplikácie nainštalovaná", - "NotificationOptionAudioPlayback": "Spustené prehrávanie audia", - "NotificationOptionAudioPlaybackStopped": "Zastavené prehrávanie audia", - "NotificationOptionCameraImageUploaded": "Nahraný obrázok z fotoaparátu", + "NotificationOptionApplicationUpdateInstalled": "Aktualizácia aplikácie bola nainštalovaná", + "NotificationOptionAudioPlayback": "Prehrávanie zvuku bolo spustené", + "NotificationOptionAudioPlaybackStopped": "Prehrávanie zvuku bolo zastavené", + "NotificationOptionCameraImageUploaded": "Obrázok z fotoaparátu bol nahraný", "NotificationOptionInstallationFailed": "Chyba inštalácie", - "NotificationOptionNewLibraryContent": "Pridaný nový obsah", - "NotificationOptionPluginError": "Chyba rozšírenia", - "NotificationOptionPluginInstalled": "Rozšírenie nainštalované", - "NotificationOptionPluginUninstalled": "Rozšírenie odinštalované", - "NotificationOptionPluginUpdateInstalled": "Aktualizácia rozšírenia nainštalovaná", + "NotificationOptionNewLibraryContent": "Bol pridaný nový obsah", + "NotificationOptionPluginError": "Chyba zásuvného modulu", + "NotificationOptionPluginInstalled": "Bol nainštalovaný zásuvný modul", + "NotificationOptionPluginUninstalled": "Zásuvný modul bol odinštalovaný", + "NotificationOptionPluginUpdateInstalled": "Bola nainštalovaná aktualizácia zásuvného modulu", "NotificationOptionServerRestartRequired": "Vyžaduje sa reštart servera", "NotificationOptionTaskFailed": "Naplánovaná úloha zlyhala", - "NotificationOptionUserLockedOut": "User locked out", + "NotificationOptionUserLockedOut": "Používateľ je uzamknutý", "NotificationOptionVideoPlayback": "Spustené prehrávanie videa", "NotificationOptionVideoPlaybackStopped": "Zastavené prehrávanie videa", "Photos": "Fotky", - "Playlists": "Zoznamy skladieb", - "Plugin": "Rozšírenie", + "Playlists": "Playlisty", + "Plugin": "Zásuvný modul", "PluginInstalledWithName": "{0} bol nainštalovaný", "PluginUninstalledWithName": "{0} bol odinštalovaný", "PluginUpdatedWithName": "{0} bol aktualizovaný", "ProviderValue": "Poskytovateľ: {0}", "ScheduledTaskFailedWithName": "{0} zlyhalo", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Series", + "ScheduledTaskStartedWithName": "{0} zahájených", + "ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart", + "Shows": "Seriály", "Songs": "Skladby", - "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Skúste to prosím o chvíľu znova.", + "StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.", "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Titulky pre {0} stiahnuté", - "Sync": "Sync", + "SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo", + "Sync": "Synchronizácia", "System": "Systém", - "TvShows": "TV Shows", + "TvShows": "TV seriály", "User": "Používateľ", "UserCreatedWithName": "Používateľ {0} bol vytvorený", "UserDeletedWithName": "Používateľ {0} bol vymazaný", "UserDownloadingItemWithValues": "{0} sťahuje {1}", - "UserLockedOutWithName": "User {0} has been locked out", + "UserLockedOutWithName": "Používateľ {0} bol vymknutý", "UserOfflineFromDevice": "{0} sa odpojil od {1}", "UserOnlineFromDevice": "{0} je online z {1}", - "UserPasswordChangedWithName": "Heslo používateľa {0} zmenené", - "UserPolicyUpdatedWithName": "User policy has been updated for {0}", - "UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1}", - "UserStoppedPlayingItemWithValues": "{0} zastavil prehrávanie {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserPasswordChangedWithName": "Heslo používateľa {0} bolo zmenené", + "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í", "ValueSpecialEpisodeName": "Špeciál - {0}", - "VersionNumber": "Verzia {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 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é 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 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", + "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 e850257d4..a6fcbd3e2 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -1,97 +1,124 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", + "Albums": "Albumi", + "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}", + "Application": "Aplikacija", + "Artists": "Izvajalci", + "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil", + "Books": "Knjige", + "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 iz {0}", + "Favorites": "Priljubljeno", + "Folders": "Mape", + "Genres": "Zvrsti", + "HeaderAlbumArtists": "Izvajalci albuma", + "HeaderContinueWatching": "Nadaljuj ogled", + "HeaderFavoriteAlbums": "Priljubljeni albumi", + "HeaderFavoriteArtists": "Priljubljeni izvajalci", + "HeaderFavoriteEpisodes": "Priljubljene epizode", + "HeaderFavoriteShows": "Priljubljene serije", + "HeaderFavoriteSongs": "Priljubljene pesmi", + "HeaderLiveTV": "TV v živo", + "HeaderNextUp": "Sledi", + "HeaderRecordingGroups": "Zbirke posnetkov", + "HomeVideos": "Domači posnetki", + "Inherit": "Podeduj", + "ItemAddedWithName": "{0} je dodan v knjižnico", + "ItemRemovedWithName": "{0} je bil odstranjen iz knjižnice", + "LabelIpAddressValue": "IP naslov: {0}", + "LabelRunningTimeValue": "Čas trajanja: {0}", + "Latest": "Najnovejše", + "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": "Mešane vsebine", + "Movies": "Filmi", + "Music": "Glasba", + "MusicVideos": "Glasbeni videi", + "NameInstallFailed": "{0} namestitev neuspešna", + "NameSeasonNumber": "Sezona {0}", + "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 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", + "NotificationOptionPluginInstalled": "Dodatek nameščen", + "NotificationOptionPluginUninstalled": "Dodatek odstranjen", + "NotificationOptionPluginUpdateInstalled": "Posodobitev dodatka nameščena", + "NotificationOptionServerRestartRequired": "Potreben je ponovni zagon strežnika", + "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": "Dodatek", + "PluginInstalledWithName": "{0} je bil nameščen", + "PluginUninstalledWithName": "{0} je bil odstranjen", + "PluginUpdatedWithName": "{0} je bil posodobljen", + "ProviderValue": "Ponudnik: {0}", + "ScheduledTaskFailedWithName": "{0} ni uspelo", + "ScheduledTaskStartedWithName": "{0} začeto", + "ServerNameNeedsToBeRestarted": "{0} mora biti ponovno zagnan", "Shows": "Serije", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "Songs": "Pesmi", + "StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", - "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "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": "Special - {0}", - "VersionNumber": "Version {0}" + "SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}", + "Sync": "Sinhroniziraj", + "System": "Sistem", + "TvShows": "TV serije", + "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 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": "Bonus - {0}", + "VersionNumber": "Različica {0}", + "TaskDownloadMissingSubtitles": "Prenesi manjkajoče podnapise", + "TaskRefreshChannelsDescription": "Osveži podatke spletnih kanalov.", + "TaskRefreshChannels": "Osveži kanale", + "TaskCleanTranscodeDescription": "Izbriše več kot dan stare datoteke prekodiranja.", + "TaskCleanTranscode": "Počisti mapo prekodiranja", + "TaskUpdatePluginsDescription": "Prenese in namesti posodobitve za dodatke, ki imajo omogočene samodejne posodobitve.", + "TaskUpdatePlugins": "Posodobi dodatke", + "TaskRefreshPeopleDescription": "Osveži metapodatke za igralce in režiserje v vaši knjižnici.", + "TaskRefreshPeople": "Osveži osebe", + "TaskCleanLogsDescription": "Izbriše dnevniške datoteke starejše od {0} dni.", + "TaskCleanLogs": "Počisti mapo 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.", + "TaskRefreshChapterImages": "Izvleči slike poglavij", + "TaskCleanCacheDescription": "Izbriše predpomnjene datoteke, ki niso več potrebne.", + "TaskCleanCache": "Počisti mapo predpomnilnika", + "TasksChannelsCategory": "Spletni kanali", + "TasksApplicationCategory": "Aplikacija", + "TasksLibraryCategory": "Knjižnica", + "TasksMaintenanceCategory": "Vzdrževanje", + "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..066ef5587 --- /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": "Video 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 new file mode 100644 index 000000000..15fb34186 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -0,0 +1,121 @@ +{ + "UserPolicyUpdatedWithName": "Корисничке смернице ажуриране за {0}", + "NotificationOptionUserLockedOut": "Корисник закључан", + "VersionNumber": "Верзија {0}", + "ValueSpecialEpisodeName": "Специјал - {0}", + "ValueHasBeenAddedToLibrary": "{0} је додато у вашу медијску библиотеку", + "UserStoppedPlayingItemWithValues": "{0} завршио пуштање {1} на {2}", + "UserStartedPlayingItemWithValues": "{0} пушта {1} на {2}", + "UserPasswordChangedWithName": "Лозинка је промењена за корисника {0}", + "UserOnlineFromDevice": "{0} је на вези од {1}", + "UserOfflineFromDevice": "{0} је прекинуо/а везу са {1}", + "UserLockedOutWithName": "Корисник {0} је закључан", + "UserDownloadingItemWithValues": "{0} преузима {1}", + "UserDeletedWithName": "Корисник {0} је обрисан", + "UserCreatedWithName": "Корисник {0} је направљен", + "User": "Корисник", + "TvShows": "ТВ серије", + "System": "Систем", + "Sync": "Усклади", + "SubtitleDownloadFailureFromForItem": "Неуспело преузимање титлова за {1} са {0}", + "StartupEmbyServerIsLoading": "Џелифин сервер се подиже. Покушајте поново убрзо.", + "Songs": "Песме", + "Shows": "Серије", + "ServerNameNeedsToBeRestarted": "{0} треба поново покренути", + "ScheduledTaskStartedWithName": "{0} покренуто", + "ScheduledTaskFailedWithName": "{0} неуспело", + "ProviderValue": "Пружалац: {0}", + "PluginUpdatedWithName": "{0} ажуриран", + "PluginUninstalledWithName": "{0} деинсталиран", + "PluginInstalledWithName": "{0} инсталиран", + "Plugin": "Прикључак", + "Playlists": "Листе", + "Photos": "Фотографије", + "NotificationOptionVideoPlaybackStopped": "Заустављено пуштање видеа", + "NotificationOptionVideoPlayback": "Покренуто пуштање видеа", + "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": "ИП адреса: {0}", + "ItemRemovedWithName": "{0} уклоњено из библиотеке", + "ItemAddedWithName": "{0} додато у библиотеку", + "Inherit": "Наследи", + "HomeVideos": "Кућни видео", + "HeaderRecordingGroups": "Групе снимања", + "HeaderNextUp": "Следи", + "HeaderLiveTV": "ТВ уживо", + "HeaderFavoriteSongs": "Омиљене песме", + "HeaderFavoriteShows": "Омиљене серије", + "HeaderFavoriteEpisodes": "Омиљене епизоде", + "HeaderFavoriteArtists": "Омиљени извођачи", + "HeaderFavoriteAlbums": "Омиљени албуми", + "HeaderContinueWatching": "Настави гледање", + "HeaderAlbumArtists": "Извођачи албума", + "Genres": "Жанрови", + "Folders": "Фасцикле", + "Favorites": "Омиљено", + "FailedLoginAttemptWithUserName": "Неуспела пријава са {0}", + "DeviceOnlineWithName": "{0} је повезан", + "DeviceOfflineWithName": "{0} је прекинуо везу", + "Collections": "Колекције", + "ChapterNameValue": "Поглавље {0}", + "Channels": "Канали", + "CameraImageUploadedFrom": "Нова фотографија је учитана са {0}", + "Books": "Књиге", + "AuthenticationSucceededWithUserName": "{0} Успешна аутентикација", + "Artists": "Извођачи", + "Application": "Апликација", + "AppDeviceValues": "Апликација: {0}, Уређај: {1}", + "Albums": "Албуми", + "TaskDownloadMissingSubtitlesDescription": "Претражује интернет за недостајуће титлове на основу конфигурације метаподатака.", + "TaskDownloadMissingSubtitles": "Преузмите недостајуће титлове", + "TaskRefreshChannelsDescription": "Освежава информације о интернет каналу.", + "TaskRefreshChannels": "Освежи канале", + "TaskCleanTranscodeDescription": "Брише датотеке за кодирање старије од једног дана.", + "TaskCleanTranscode": "Очистите директоријум преноса", + "TaskUpdatePluginsDescription": "Преузима и инсталира исправке за додатке који су конфигурисани за аутоматско ажурирање.", + "TaskUpdatePlugins": "Ажурирајте додатке", + "TaskRefreshPeopleDescription": "Ажурира метаподатке за глумце и редитеље у вашој медијској библиотеци.", + "TaskRefreshPeople": "Освежите кориснике", + "TaskCleanLogsDescription": "Брише логове старије од {0} дана.", + "TaskCleanLogs": "Очистите директоријум логова", + "TaskRefreshLibraryDescription": "Скенира вашу медијску библиотеку за нове датотеке и освежава метаподатке.", + "TaskRefreshLibrary": "Скенирај библиотеку медија", + "TaskRefreshChapterImagesDescription": "Ствара сличице за видео записе који имају поглавља.", + "TaskRefreshChapterImages": "Издвоји слике из поглавља", + "TaskCleanCacheDescription": "Брише Кеш фајлове који више нису потребни систему.", + "TaskCleanCache": "Очистите Кеш Директоријум", + "TasksChannelsCategory": "Интернет канали", + "TasksApplicationCategory": "Апликација", + "TasksLibraryCategory": "Библиотека", + "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 fb2761a7d..6c772c6a2 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -1,32 +1,31 @@ { "Albums": "Album", - "AppDeviceValues": "App: {0}, Enhet: {1}", - "Application": "App", + "AppDeviceValues": "Applikation: {0}, Enhet: {1}", + "Application": "Applikation", "Artists": "Artister", "AuthenticationSucceededWithUserName": "{0} har autentiserats", "Books": "Böcker", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "En ny kamerabild har laddats upp från {0}", "Channels": "Kanaler", "ChapterNameValue": "Kapitel {0}", "Collections": "Samlingar", - "DeviceOfflineWithName": "{0} har tappat anslutningen", + "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": "Camera Uploads", - "HeaderContinueWatching": "Fortsätt kolla på", + "HeaderAlbumArtists": "Artistens album", + "HeaderContinueWatching": "Fortsätt kolla", "HeaderFavoriteAlbums": "Favoritalbum", "HeaderFavoriteArtists": "Favoritartister", "HeaderFavoriteEpisodes": "Favoritavsnitt", "HeaderFavoriteShows": "Favoritserier", "HeaderFavoriteSongs": "Favoritlåtar", "HeaderLiveTV": "Live-TV", - "HeaderNextUp": "Nästa på tur", + "HeaderNextUp": "Nästa", "HeaderRecordingGroups": "Inspelningsgrupper", - "HomeVideos": "Hemvideor", + "HomeVideos": "Hemmavideor", "Inherit": "Ärv", "ItemAddedWithName": "{0} lades till i biblioteket", "ItemRemovedWithName": "{0} togs bort från biblioteket", @@ -34,23 +33,23 @@ "LabelRunningTimeValue": "Speltid: {0}", "Latest": "Senaste", "MessageApplicationUpdated": "Jellyfin Server har uppdaterats", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "MessageApplicationUpdatedTo": "Jellyfin Server har uppdaterats till {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Serverinställningarna {0} har uppdaterats", - "MessageServerConfigurationUpdated": "Server konfigurationen har uppdaterats", + "MessageServerConfigurationUpdated": "Serverkonfigurationen har uppdaterats", "MixedContent": "Blandat innehåll", "Movies": "Filmer", "Music": "Musik", - "MusicVideos": "Musikvideos", - "NameInstallFailed": "{0} installation failed", + "MusicVideos": "Musikvideor", + "NameInstallFailed": "{0} installationen misslyckades", "NameSeasonNumber": "Säsong {0}", "NameSeasonUnknown": "Okänd säsong", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NewVersionIsAvailable": "En ny version av Jellyfin Server är tillgänglig att hämta.", "NotificationOptionApplicationUpdateAvailable": "Ny programversion tillgänglig", "NotificationOptionApplicationUpdateInstalled": "Programuppdatering installerad", "NotificationOptionAudioPlayback": "Ljuduppspelning har påbörjats", - "NotificationOptionAudioPlaybackStopped": "Ljuduppspelning stoppad", + "NotificationOptionAudioPlaybackStopped": "Ljuduppspelning stoppades", "NotificationOptionCameraImageUploaded": "Kamerabild har laddats upp", - "NotificationOptionInstallationFailed": "Fel vid installation", + "NotificationOptionInstallationFailed": "Installationen misslyckades", "NotificationOptionNewLibraryContent": "Nytt innehåll har lagts till", "NotificationOptionPluginError": "Fel uppstod med tillägget", "NotificationOptionPluginInstalled": "Tillägg har installerats", @@ -60,7 +59,7 @@ "NotificationOptionTaskFailed": "Schemalagd aktivitet har misslyckats", "NotificationOptionUserLockedOut": "Användare har låsts ut", "NotificationOptionVideoPlayback": "Videouppspelning har påbörjats", - "NotificationOptionVideoPlaybackStopped": "Videouppspelning stoppad", + "NotificationOptionVideoPlaybackStopped": "Videouppspelning stoppades", "Photos": "Bilder", "Playlists": "Spellistor", "Plugin": "Tillägg", @@ -69,14 +68,13 @@ "PluginUpdatedWithName": "{0} uppdaterades", "ProviderValue": "Källa: {0}", "ScheduledTaskFailedWithName": "{0} misslyckades", - "ScheduledTaskStartedWithName": "{0} startad", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", + "ScheduledTaskStartedWithName": "{0} startades", + "ServerNameNeedsToBeRestarted": "{0} behöver startas om", "Shows": "Serier", "Songs": "Låtar", - "StartupEmbyServerIsLoading": "Jellyfin server arbetar. Pröva igen inom kort.", + "StartupEmbyServerIsLoading": "Jellyfin Server arbetar. Pröva igen snart.", "SubtitleDownloadFailureForItem": "Nerladdning av undertexter för {0} misslyckades", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Undertexter har laddats ner till {0}", + "SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} för {1}", "Sync": "Synk", "System": "System", "TvShows": "TV-serier", @@ -89,9 +87,38 @@ "UserOnlineFromDevice": "{0} är uppkopplad från {1}", "UserPasswordChangedWithName": "Lösenordet för {0} har ändrats", "UserPolicyUpdatedWithName": "Användarpolicyn har uppdaterats för {0}", - "UserStartedPlayingItemWithValues": "{0} har börjat spela upp {1}", - "UserStoppedPlayingItemWithValues": "{0} har avslutat uppspelningen av {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserStartedPlayingItemWithValues": "{0} spelar upp {1} på {2}", + "UserStoppedPlayingItemWithValues": "{0} har avslutat uppspelningen av {1} på {2}", + "ValueHasBeenAddedToLibrary": "{0} har lagts till i ditt mediebibliotek", "ValueSpecialEpisodeName": "Specialavsnitt - {0}", - "VersionNumber": "Version {0}" + "VersionNumber": "Version {0}", + "TaskDownloadMissingSubtitlesDescription": "Söker på internet efter saknade undertexter baserad på metadatas konfiguration.", + "TaskDownloadMissingSubtitles": "Ladda ned saknade undertexter", + "TaskRefreshChannelsDescription": "Uppdaterar information för internetkanaler.", + "TaskRefreshChannels": "Uppdatera kanaler", + "TaskCleanTranscodeDescription": "Raderar transkodningsfiler som är mer än en dag gamla.", + "TaskCleanTranscode": "Töm transkodningskatalog", + "TaskUpdatePluginsDescription": "Laddar ned och installerar uppdateringar till insticksprogram som är konfigurerade att uppdateras automatiskt.", + "TaskUpdatePlugins": "Uppdatera insticksprogram", + "TaskRefreshPeopleDescription": "Uppdaterar metadata för skådespelare och regissörer i ditt mediabibliotek.", + "TaskCleanLogsDescription": "Raderar loggfiler som är mer än {0} dagar gamla.", + "TaskCleanLogs": "Töm loggkatalog", + "TaskRefreshLibraryDescription": "Söker igenom ditt mediabibliotek efter nya filer och förnyar metadata.", + "TaskRefreshLibrary": "Genomsök mediabibliotek", + "TaskRefreshChapterImagesDescription": "Skapa miniatyrbilder för videor med kapitel.", + "TaskRefreshChapterImages": "Extrahera kapitelbilder", + "TaskCleanCacheDescription": "Radera cachade filer som systemet inte längre behöver.", + "TaskCleanCache": "Rensa cachekatalog", + "TasksChannelsCategory": "Internetkanaler", + "TasksApplicationCategory": "Applikation", + "TasksLibraryCategory": "Bibliotek", + "TasksMaintenanceCategory": "Underhåll", + "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", + "TaskOptimizeDatabaseDescription": "Komprimerar databasen och trunkerar ledigt utrymme. Prestandan kan förbättras genom att köra denna task efter att du har skannat biblioteket eller gjort andra förändringar som indikerar att databasen har modifierats." } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json new file mode 100644 index 000000000..d6e9aa8e5 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -0,0 +1,123 @@ +{ + "VersionNumber": "பதிப்பு {0}", + "ValueSpecialEpisodeName": "சிறப்பு - {0}", + "TasksMaintenanceCategory": "பராமரிப்பு", + "TaskCleanCache": "தற்காலிக சேமிப்பு கோப்பகத்தை சுத்தம் செய்யவும்", + "TaskRefreshChapterImages": "அத்தியாயப் படங்களை பிரித்தெடுக்கவும்", + "TaskRefreshPeople": "மக்களைப் புதுப்பிக்கவும்", + "TaskCleanTranscode": "டிரான்ஸ்கோட் கோப்பகத்தை சுத்தம் செய்யவும்", + "TaskRefreshChannelsDescription": "இணையச் சேனல் தகவல்களைப் புதுப்பிக்கிறது.", + "System": "ஒருங்கியம்", + "NotificationOptionTaskFailed": "திட்டமிடப்பட்ட பணி தோல்வியடைந்தது", + "NotificationOptionPluginUpdateInstalled": "உட்செருகி புதுப்பிக்கப்பட்டது", + "NotificationOptionPluginUninstalled": "உட்செருகி நீக்கப்பட்டது", + "NotificationOptionPluginInstalled": "உட்செருகி நிறுவப்பட்டது", + "NotificationOptionPluginError": "உட்செருகி செயலிழந்தது", + "NotificationOptionCameraImageUploaded": "புகைப்படம் பதிவேற்றப்பட்டது", + "MixedContent": "கலப்பு உள்ளடக்கங்கள்", + "MessageServerConfigurationUpdated": "சேவையக அமைப்புகள் புதுப்பிக்கப்பட்டன", + "MessageApplicationUpdatedTo": "ஜெல்லிஃபின் சேவையகம் {0} இற்கு புதுப்பிக்கப்பட்டது", + "MessageApplicationUpdated": "ஜெல்லிஃபின் சேவையகம் புதுப்பிக்கப்பட்டது", + "Inherit": "மரபுரிமையாகப் பெறு", + "HeaderRecordingGroups": "பதிவு குழுக்கள்", + "Folders": "கோப்புறைகள்", + "FailedLoginAttemptWithUserName": "{0} இல் இருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது", + "DeviceOnlineWithName": "{0} இணைக்கப்பட்டது", + "DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது", + "Collections": "தொகுப்புகள்", + "CameraImageUploadedFrom": "{0} இல் இருந்து புதிய புகைப்படம் பதிவேற்றப்பட்டது", + "AppDeviceValues": "செயலி: {0}, சாதனம்: {1}", + "TaskDownloadMissingSubtitles": "விடுபட்டுபோன வசன வரிகளைப் பதிவிறக்கு", + "TaskRefreshChannels": "சேனல்களை புதுப்பி", + "TaskUpdatePlugins": "உட்செருகிகளை புதுப்பி", + "TaskRefreshLibrary": "ஊடக நூலகத்தை ஆராய்", + "TasksChannelsCategory": "இணைய சேனல்கள்", + "TasksApplicationCategory": "செயலி", + "TasksLibraryCategory": "நூலகம்", + "UserPolicyUpdatedWithName": "பயனர் கொள்கை {0} இற்கு புதுப்பிக்கப்பட்டுள்ளது", + "UserPasswordChangedWithName": "{0} பயனருக்கு கடவுச்சொல் மாற்றப்பட்டுள்ளது", + "UserLockedOutWithName": "பயனர் {0} முடக்கப்பட்டார்", + "UserDownloadingItemWithValues": "{0} ஆல் {1} பதிவிறக்கப்படுகிறது", + "UserDeletedWithName": "பயனர் {0} நீக்கப்பட்டார்", + "UserCreatedWithName": "பயனர் {0} உருவாக்கப்பட்டார்", + "User": "பயனர்", + "TvShows": "தொலைக்காட்சித் தொடர்கள்", + "Sync": "ஒத்திசைவு", + "StartupEmbyServerIsLoading": "ஜெல்லிஃபின் சேவையகம் துவங்குகிறது. சிறிது நேரம் கழித்து முயற்சிக்கவும்.", + "Songs": "பாடல்கள்", + "Shows": "நிகழ்ச்சிகள்", + "ServerNameNeedsToBeRestarted": "{0} மறுதொடக்கம் செய்யப்பட வேண்டும்", + "ScheduledTaskStartedWithName": "{0} துவங்கியது", + "ScheduledTaskFailedWithName": "{0} தோல்வியடைந்தது", + "ProviderValue": "வழங்குநர்: {0}", + "PluginUpdatedWithName": "{0} புதுப்பிக்கப்பட்டது", + "PluginUninstalledWithName": "{0} நீக்கப்பட்டது", + "PluginInstalledWithName": "{0} நிறுவப்பட்டது", + "Plugin": "உட்செருகி", + "Playlists": "தொடர் பட்டியல்கள்", + "Photos": "புகைப்படங்கள்", + "NotificationOptionVideoPlaybackStopped": "நிகழ்பட ஒளிபரப்பு நிறுத்தப்பட்டது", + "NotificationOptionVideoPlayback": "நிகழ்பட ஒளிபரப்பு துவங்கியது", + "NotificationOptionUserLockedOut": "பயனர் கணக்கு முடக்கப்பட்டது", + "NotificationOptionServerRestartRequired": "சேவையக மறுதொடக்கம் தேவை", + "NotificationOptionNewLibraryContent": "புதிய உள்ளடக்கங்கள் சேர்க்கப்பட்டன", + "NotificationOptionInstallationFailed": "நிறுவல் தோல்வியடைந்தது", + "NotificationOptionAudioPlaybackStopped": "ஒலி இசைத்தல் நிறுத்தப்பட்டது", + "NotificationOptionAudioPlayback": "ஒலி இசைக்கத் துவங்கியுள்ளது", + "NotificationOptionApplicationUpdateInstalled": "செயலி புதுப்பிக்கப்பட்டது", + "NotificationOptionApplicationUpdateAvailable": "செயலியினை புதுப்பிக்கலாம்", + "NameSeasonUnknown": "அறியப்படாத பருவம்", + "NameSeasonNumber": "பருவம் {0}", + "NameInstallFailed": "{0} நிறுவல் தோல்வியடைந்தது", + "MusicVideos": "இசை கானொளி", + "Music": "இசை", + "Movies": "திரைப்படங்கள்", + "Latest": "புதியவை", + "LabelRunningTimeValue": "ஓடும் நேரம்: {0}", + "LabelIpAddressValue": "ஐபி முகவரி: {0}", + "ItemRemovedWithName": "{0} நூலகத்திலிருந்து அகற்றப்பட்டது", + "ItemAddedWithName": "{0} நூலகத்தில் சேர்க்கப்பட்டது", + "HeaderNextUp": "அடுத்தது", + "HeaderLiveTV": "நேரடித் தொலைக்காட்சி", + "HeaderFavoriteSongs": "பிடித்த பாடல்கள்", + "HeaderFavoriteShows": "பிடித்த தொடர்கள்", + "HeaderFavoriteEpisodes": "பிடித்த அத்தியாயங்கள்", + "HeaderFavoriteArtists": "பிடித்த கலைஞர்கள்", + "HeaderFavoriteAlbums": "பிடித்த ஆல்பங்கள்", + "HeaderContinueWatching": "தொடர்ந்து பார்", + "HeaderAlbumArtists": "இசைக் கலைஞர்கள்", + "Genres": "வகைகள்", + "Favorites": "பிடித்தவை", + "ChapterNameValue": "அத்தியாயம் {0}", + "Channels": "சேனல்கள்", + "Books": "புத்தகங்கள்", + "AuthenticationSucceededWithUserName": "{0} வெற்றிகரமாக அங்கீகரிக்கப்பட்டது", + "Artists": "கலைஞர்கள்", + "Application": "செயலி", + "Albums": "ஆல்பங்கள்", + "NewVersionIsAvailable": "ஜெல்லிஃபின் சேவையகத்தின் புதிய பதிப்பு பதிவிறக்கத்திற்கு கிடைக்கிறது.", + "MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது", + "TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.", + "UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது", + "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", + "TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.", + "TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.", + "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.", + "TaskRefreshPeopleDescription": "உங்கள் ஊடக நூலகத்தில் உள்ள நடிகர்கள் மற்றும் இயக்குனர்களுக்கான மீத்தரவை புதுப்பிக்கும்.", + "TaskCleanLogsDescription": "{0} நாட்களுக்கு மேல் இருக்கும் பதிவு கோப்புகளை நீக்கும்.", + "TaskCleanLogs": "பதிவு அடைவை சுத்தம் செய்யுங்கள்", + "TaskRefreshLibraryDescription": "புதிய கோப்புகளுக்காக உங்கள் ஊடக நூலகத்தை ஆராய்ந்து மீத்தரவை புதுப்பிக்கும்.", + "TaskRefreshChapterImagesDescription": "அத்தியாயங்களைக் கொண்ட வீடியோக்களுக்கான சிறு உருவங்களை உருவாக்குகிறது.", + "ValueHasBeenAddedToLibrary": "உங்கள் மீடியா நூலகத்தில் {0} சேர்க்கப்பட்டது", + "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்", + "HomeVideos": "முகப்பு வீடியோக்கள்", + "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 new file mode 100644 index 000000000..e26010423 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -0,0 +1,121 @@ +{ + "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": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว", + "NameSeasonUnknown": "ไม่ทราบซีซัน", + "NameSeasonNumber": "ซีซัน {0}", + "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว", + "MusicVideos": "มิวสิควิดีโอ", + "Music": "ดนตรี", + "Movies": "ภาพยนตร์", + "MixedContent": "เนื้อหาผสม", + "MessageServerConfigurationUpdated": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์แล้ว", + "MessageNamedServerConfigurationUpdatedWithValue": "อัปเดตการกำหนดค่าเซิร์ฟเวอร์ในส่วน {0} แล้ว", + "MessageApplicationUpdatedTo": "เซิร์ฟเวอร์ Jellyfin ได้รับการอัปเดตเป็น {0}", + "MessageApplicationUpdated": "อัพเดตเซิร์ฟเวอร์ Jellyfin แล้ว", + "Latest": "ล่าสุด", + "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}", + "LabelIpAddressValue": "ที่อยู่ IP: {0}", + "ItemRemovedWithName": "{0} ถูกลบออกจากไลบรารี", + "ItemAddedWithName": "{0} ถูกเพิ่มลงในไลบรารีแล้ว", + "Inherit": "สืบทอด", + "HomeVideos": "โฮมวิดีโอ", + "HeaderRecordingGroups": "กลุ่มการบันทึก", + "HeaderNextUp": "ถัดไป", + "HeaderLiveTV": "ทีวีสด", + "HeaderFavoriteSongs": "เพลงที่ชื่นชอบ", + "HeaderFavoriteShows": "รายการที่ชื่นชอบ", + "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ", + "HeaderFavoriteArtists": "ศิลปินที่ชื่นชอบ", + "HeaderFavoriteAlbums": "อัมบั้มที่ชื่นชอบ", + "HeaderContinueWatching": "ดูต่อ", + "HeaderAlbumArtists": "ศิลปินอัลบั้ม", + "Genres": "ประเภท", + "Folders": "โฟลเดอร์", + "Favorites": "รายการโปรด", + "FailedLoginAttemptWithUserName": "ความพยายามในการเข้าสู่ระบบล้มเหลวจาก {0}", + "DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว", + "DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว", + "Collections": "คอลเลกชัน", + "ChapterNameValue": "บท {0}", + "Channels": "ช่อง", + "CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}", + "Books": "หนังสือ", + "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จแล้ว", + "Artists": "ศิลปิน", + "Application": "แอปพลิเคชัน", + "AppDeviceValues": "แอป: {0}, อุปกรณ์: {1}", + "Albums": "อัลบั้ม", + "ScheduledTaskStartedWithName": "{0} เริ่มต้น", + "ScheduledTaskFailedWithName": "{0} ล้มเหลว", + "Songs": "เพลง", + "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 495f82db6..e661299c4 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -1,97 +1,124 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favori Showlar", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "Albums": "Albümler", + "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}", + "Application": "Uygulama", + "Artists": "Sanatçılar", + "AuthenticationSucceededWithUserName": "{0} kimlik başarıyla doğrulandı", + "Books": "Kitaplar", + "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", + "Channels": "Kanallar", + "ChapterNameValue": "Bölüm {0}", + "Collections": "Koleksiyon", + "DeviceOfflineWithName": "{0} bağlantısı kesildi", + "DeviceOnlineWithName": "{0} bağlı", + "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu", + "Favorites": "Favoriler", + "Folders": "Klasörler", + "Genres": "Türler", + "HeaderAlbumArtists": "Albüm Sanatçıları", + "HeaderContinueWatching": "İzlemeye Devam Et", + "HeaderFavoriteAlbums": "Favori Albümler", + "HeaderFavoriteArtists": "Favori Sanatçılar", + "HeaderFavoriteEpisodes": "Favori Bölümler", + "HeaderFavoriteShows": "Favori Diziler", + "HeaderFavoriteSongs": "Favori Şarkılar", + "HeaderLiveTV": "Canlı TV", + "HeaderNextUp": "Gelecek Hafta", + "HeaderRecordingGroups": "Kayıt Grupları", + "HomeVideos": "Ana sayfa videoları", + "Inherit": "Devral", + "ItemAddedWithName": "{0} kütüphaneye eklendi", + "ItemRemovedWithName": "{0} kütüphaneden silindi", + "LabelIpAddressValue": "IP adresi: {0}", + "LabelRunningTimeValue": "Çalışma süresi: {0}", + "Latest": "En son", + "MessageApplicationUpdated": "Jellyfin Sunucusu güncellendi", + "MessageApplicationUpdatedTo": "Jellyfin Sunucusu {0} sürümüne güncellendi", + "MessageNamedServerConfigurationUpdatedWithValue": "Sunucu ayar kısmı {0} güncellendi", + "MessageServerConfigurationUpdated": "Sunucu ayarları güncellendi", + "MixedContent": "Karışık içerik", + "Movies": "Filmler", + "Music": "Müzik", + "MusicVideos": "Müzik videoları", + "NameInstallFailed": "{0} kurulumu başarısız", + "NameSeasonNumber": "Sezon {0}", + "NameSeasonUnknown": "Bilinmeyen Sezon", + "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ı", + "NotificationOptionAudioPlaybackStopped": "Ses çalma durduruldu", + "NotificationOptionCameraImageUploaded": "Kamera fotoğrafı yüklendi", + "NotificationOptionInstallationFailed": "Kurulum hatası", + "NotificationOptionNewLibraryContent": "Yeni içerik eklendi", + "NotificationOptionPluginError": "Eklenti hatası", + "NotificationOptionPluginInstalled": "Eklenti yüklendi", + "NotificationOptionPluginUninstalled": "Eklenti kaldırıldı", + "NotificationOptionPluginUpdateInstalled": "Eklenti güncellemesi yüklendi", + "NotificationOptionServerRestartRequired": "Sunucu yeniden başlatma gerekli", + "NotificationOptionTaskFailed": "Zamanlanmış görev hatası", + "NotificationOptionUserLockedOut": "Kullanıcı kitlendi", + "NotificationOptionVideoPlayback": "Video oynatma başladı", + "NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu", + "Photos": "Fotoğraflar", + "Playlists": "Çalma listeleri", + "Plugin": "Eklenti", + "PluginInstalledWithName": "{0} yüklendi", + "PluginUninstalledWithName": "{0} kaldırıldı", + "PluginUpdatedWithName": "{0} güncellendi", + "ProviderValue": "Sağlayıcı: {0}", + "ScheduledTaskFailedWithName": "{0} başarısız oldu", + "ScheduledTaskStartedWithName": "{0} başladı", + "ServerNameNeedsToBeRestarted": "{0} yeniden başlatılması gerekiyor", + "Shows": "Diziler", + "Songs": "Şarkılar", + "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", - "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "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": "Special - {0}", - "VersionNumber": "Version {0}" + "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} 'dan indirilemedi", + "Sync": "Eşzamanlama", + "System": "Sistem", + "TvShows": "Diziler", + "User": "Kullanıcı", + "UserCreatedWithName": "{0} kullanıcısı oluşturuldu", + "UserDeletedWithName": "Kullanıcı {0} silindi", + "UserDownloadingItemWithValues": "{0} indiriliyor {1}", + "UserLockedOutWithName": "Kullanıcı {0} kitlendi", + "UserOfflineFromDevice": "{0}, {1} ile bağlantısı kesildi", + "UserOnlineFromDevice": "{0}, {1} çevrimiçi", + "UserPasswordChangedWithName": "{0} kullanıcısı için şifre değiştirildi", + "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 kütüphanenize {0} eklendi", + "ValueSpecialEpisodeName": "Özel - {0}", + "VersionNumber": "Sürüm {0}", + "TaskCleanCache": "Geçici dosya klasörünü temizle", + "TasksChannelsCategory": "İnternet kanalları", + "TasksApplicationCategory": "Uygulama", + "TasksLibraryCategory": "Kütüphane", + "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ü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 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.", + "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 new file mode 100644 index 000000000..e99ed6f0b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -0,0 +1,123 @@ +{ + "MusicVideos": "Відеокліпи", + "Music": "Музика", + "Movies": "Фільми", + "MessageApplicationUpdatedTo": "Jellyfin Server оновлено до версії {0}", + "MessageApplicationUpdated": "Jellyfin Server оновлено", + "Latest": "Останні", + "LabelIpAddressValue": "IP-адреса: {0}", + "ItemRemovedWithName": "{0} видалено з медіатеки", + "ItemAddedWithName": "{0} додано до медіатеки", + "HeaderNextUp": "Наступний", + "HeaderLiveTV": "Ефірне ТБ", + "HeaderFavoriteSongs": "Улюблені пісні", + "HeaderFavoriteShows": "Улюблені шоу", + "HeaderFavoriteEpisodes": "Улюблені серії", + "HeaderFavoriteArtists": "Улюблені виконавці", + "HeaderFavoriteAlbums": "Улюблені альбоми", + "HeaderContinueWatching": "Продовжити перегляд", + "HeaderAlbumArtists": "Виконавці альбомів", + "Genres": "Жанри", + "Folders": "Каталоги", + "Favorites": "Улюблені", + "DeviceOnlineWithName": "Пристрій {0} підключився", + "DeviceOfflineWithName": "Пристрій {0} відключився", + "Collections": "Колекції", + "ChapterNameValue": "Розділ {0}", + "Channels": "Канали", + "CameraImageUploadedFrom": "Нова фотографія завантажена з {0}", + "Books": "Книги", + "AuthenticationSucceededWithUserName": "{0} успішно автентифіковано", + "Artists": "Виконавці", + "Application": "Додаток", + "AppDeviceValues": "Додаток: {0}, Пристрій: {1}", + "Albums": "Альбоми", + "NotificationOptionServerRestartRequired": "Необхідно перезапустити сервер", + "NotificationOptionPluginUpdateInstalled": "Встановлено оновлення плагіна", + "NotificationOptionPluginUninstalled": "Плагін видалено", + "NotificationOptionPluginInstalled": "Плагін встановлено", + "NotificationOptionPluginError": "Помилка плагіна", + "NotificationOptionNewLibraryContent": "Додано новий контент", + "HomeVideos": "Мої відео", + "FailedLoginAttemptWithUserName": "Невдала спроба входу від {0}", + "LabelRunningTimeValue": "Тривалість: {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 Server завантажується. Будь ласка, спробуйте трішки пізніше.", + "Songs": "Пісні", + "Shows": "Шоу", + "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити", + "ScheduledTaskStartedWithName": "{0} розпочато", + "ScheduledTaskFailedWithName": "Помилка {0}", + "ProviderValue": "Постачальник: {0}", + "PluginUpdatedWithName": "{0} оновлено", + "PluginUninstalledWithName": "{0} видалено", + "PluginInstalledWithName": "{0} встановлено", + "Plugin": "Плагін", + "Playlists": "Плейлисти", + "Photos": "Фотографії", + "NotificationOptionVideoPlaybackStopped": "Відтворення відео зупинено", + "NotificationOptionVideoPlayback": "Розпочато відтворення відео", + "NotificationOptionUserLockedOut": "Користувача заблоковано", + "NotificationOptionTaskFailed": "Помилка запланованого завдання", + "NotificationOptionInstallationFailed": "Помилка встановлення", + "NotificationOptionCameraImageUploaded": "Фотографію завантажено", + "NotificationOptionAudioPlaybackStopped": "Відтворення аудіо зупинено", + "NotificationOptionAudioPlayback": "Розпочато відтворення аудіо", + "NotificationOptionApplicationUpdateInstalled": "Встановлено оновлення додатка", + "NotificationOptionApplicationUpdateAvailable": "Доступне оновлення додатка", + "NewVersionIsAvailable": "Для завантаження доступна нова версія Jellyfin Server.", + "NameSeasonUnknown": "Сезон Невідомий", + "NameSeasonNumber": "Сезон {0}", + "NameInstallFailed": "Не вдалося встановити {0}", + "MixedContent": "Змішаний контент", + "MessageServerConfigurationUpdated": "Конфігурація сервера оновлена", + "MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено", + "Inherit": "Успадкувати", + "HeaderRecordingGroups": "Групи запису", + "Forced": "Форсовані", + "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.", + "TaskCleanActivityLog": "Очистити журнал активності", + "Undefined": "Не визначено", + "Default": "За замовчуванням", + "TaskOptimizeDatabase": "Оптимізувати базу даних", + "TaskOptimizeDatabaseDescription": "Стиснення бази даних та збільшення вільного простору. Виконання цього завдання після сканування бібліотеки або внесення інших змін, які передбачають модифікацію бази даних, може покращити продуктивність." +} diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json new file mode 100644 index 000000000..11af9fc98 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -0,0 +1,117 @@ +{ + "HeaderFavoriteAlbums": "پسندیدہ البمز", + "HeaderNextUp": "اگلا", + "HeaderFavoriteArtists": "پسندیدہ فنکار", + "HeaderAlbumArtists": "البم کے فنکار", + "Movies": "فلمیں", + "HeaderFavoriteEpisodes": "پسندیدہ اقساط", + "Collections": "مجموعہ", + "Folders": "فولڈرز", + "HeaderLiveTV": "براہ راست ٹی وی", + "Channels": "چینلز", + "HeaderContinueWatching": "دیکھنا جاری رکھیں", + "Playlists": "پلے لسٹس", + "ValueSpecialEpisodeName": "خاص - {0}", + "Shows": "شوز", + "Genres": "انواع", + "Artists": "فنکار", + "Sync": "مطابقت", + "Photos": "تصوریں", + "Albums": "البمز", + "Favorites": "پسندیدہ", + "Songs": "گانے", + "Books": "کتابیں", + "HeaderFavoriteSongs": "پسندیدہ گانے", + "HeaderFavoriteShows": "پسندیدہ شوز", + "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}", + "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": "نظام", + "SubtitleDownloadFailureFromForItem": "ذیلی عنوانات {0} سے ڈاؤن لوڈ کرنے میں ناکام {1} کے لیے", + "StartupEmbyServerIsLoading": "جیلیفن سرور لوڈ ہورہا ہے۔ براہ کرم جلد ہی دوبارہ کوشش کریں۔", + "ServerNameNeedsToBeRestarted": "{0} دوبارہ چلانے کرنے کی ضرورت ہے", + "ScheduledTaskStartedWithName": "{0} شروع", + "ScheduledTaskFailedWithName": "{0} ناکام", + "ProviderValue": "فراہم کرنے والا: {0}", + "PluginUpdatedWithName": "{0} تازہ کاری کی گئی تھی", + "PluginUninstalledWithName": "[0} ہٹا دیا گیا تھا", + "PluginInstalledWithName": "{0} انسٹال کیا گیا تھا", + "Plugin": "پلگن", + "NotificationOptionVideoPlaybackStopped": "ویڈیو پلے بیک رک گیا", + "NotificationOptionVideoPlayback": "ویڈیو پلے بیک شروع ہوا", + "NotificationOptionUserLockedOut": "صارف کو لاک آؤٹ کیا گیا", + "NotificationOptionTaskFailed": "طے شدہ کام کی ناکامی", + "NotificationOptionServerRestartRequired": "سرور دوبارہ چلانے کرنے کی ضرورت ہے", + "NotificationOptionPluginUpdateInstalled": "پلگ ان اپ ڈیٹ انسٹال", + "NotificationOptionPluginUninstalled": "پلگ ان ہٹا دیا گیا", + "NotificationOptionPluginInstalled": "پلگ ان انسٹال ہوا", + "NotificationOptionPluginError": "پلگ ان کی ناکامی", + "NotificationOptionNewLibraryContent": "نیا مواد شامل کیا گیا", + "NotificationOptionInstallationFailed": "تنصیب کی ناکامی", + "NotificationOptionCameraImageUploaded": "کیمرے کی تصویر اپ لوڈ ہوگئی", + "NotificationOptionAudioPlaybackStopped": "آڈیو پلے بیک رک گیا", + "NotificationOptionAudioPlayback": "آڈیو پلے بیک شروع ہوا", + "NotificationOptionApplicationUpdateInstalled": "پروگرام اپ ڈیٹ انسٹال ہوچکا ھے", + "NotificationOptionApplicationUpdateAvailable": "پروگرام کی تازہ کاری دستیاب ہے", + "NewVersionIsAvailable": "جیلیفن سرور کا ایک نیا ورژن ڈاؤن لوڈ کے لئے دستیاب ہے۔", + "NameSeasonUnknown": "نامعلوم باب", + "NameSeasonNumber": "باب {0}", + "NameInstallFailed": "{0} تنصیب ناکام ہوگئی", + "MusicVideos": "ویڈیو موسیقی", + "Music": "موسیقی", + "MixedContent": "مخلوط مواد", + "MessageServerConfigurationUpdated": "سرور کو اپ ڈیٹ کر دیا گیا ہے", + "MessageNamedServerConfigurationUpdatedWithValue": "سرور ضمن {0} کو ترتیب دے دیا گیا ھے", + "MessageApplicationUpdatedTo": "جیلیفن سرور کو اپ ڈیٹ کیا ہے {0}", + "MessageApplicationUpdated": "جیلیفن سرور کو اپ ڈیٹ کر دیا گیا ہے", + "Latest": "تازہ ترین", + "LabelRunningTimeValue": "چلانے کی مدت", + "LabelIpAddressValue": "آئ پی ایڈریس {0}", + "ItemRemovedWithName": "لائبریری سے ہٹا دیا گیا ھے", + "ItemAddedWithName": "[0} لائبریری میں شامل کیا گیا ھے", + "Inherit": "وراثت میں", + "HomeVideos": "ہوم ویڈیو", + "HeaderRecordingGroups": "ریکارڈنگ گروپس", + "FailedLoginAttemptWithUserName": "{0} سے لاگ ان کی ناکام کوشش", + "DeviceOnlineWithName": "{0} متصل ھو چکا ھے", + "DeviceOfflineWithName": "{0} منقطع ھو چکا ھے", + "ChapterNameValue": "باب", + "AuthenticationSucceededWithUserName": "{0} کامیابی کے ساتھ تصدیق ھوچکی ھے", + "CameraImageUploadedFrom": "ایک نئی کیمرہ تصویر اپ لوڈ کی گئی ہے {0}", + "Application": "پروگرام", + "AppDeviceValues": "پروگرام:{0}, ڈیوائس:{1}", + "Forced": "جَبری" +} diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json new file mode 100644 index 000000000..33aa0eea0 --- /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": "Bài Hát", + "Sync": "Đồng Bộ", + "ValueSpecialEpisodeName": "Đặc Biệt - {0}", + "Albums": "Tuyển Tập", + "Artists": "Ca 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 Bộ Nhớ Đệm", + "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 8910a6bce..f9df62724 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -3,24 +3,23 @@ "AppDeviceValues": "应用: {0}, 设备: {1}", "Application": "应用程序", "Artists": "艺术家", - "AuthenticationSucceededWithUserName": "{0} 成功验证", + "AuthenticationSucceededWithUserName": "{0} 认证成功", "Books": "书籍", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", + "CameraImageUploadedFrom": "新的相机图像已从 {0} 上传", "Channels": "频道", "ChapterNameValue": "章节 {0}", "Collections": "合集", "DeviceOfflineWithName": "{0} 已断开", "DeviceOnlineWithName": "{0} 已连接", "FailedLoginAttemptWithUserName": "来自 {0} 的失败登入", - "Favorites": "最爱", + "Favorites": "我的最爱", "Folders": "文件夹", "Genres": "风格", - "HeaderAlbumArtists": "专辑作家", - "HeaderCameraUploads": "相机上传", + "HeaderAlbumArtists": "专辑艺术家", "HeaderContinueWatching": "继续观看", - "HeaderFavoriteAlbums": "最爱的专辑", - "HeaderFavoriteArtists": "最爱作家", - "HeaderFavoriteEpisodes": "最爱的集", + "HeaderFavoriteAlbums": "收藏的专辑", + "HeaderFavoriteArtists": "最爱的艺术家", + "HeaderFavoriteEpisodes": "最爱的剧集", "HeaderFavoriteShows": "最爱的节目", "HeaderFavoriteSongs": "最爱的歌曲", "HeaderLiveTV": "电视直播", @@ -30,21 +29,21 @@ "Inherit": "继承", "ItemAddedWithName": "{0} 已添加到媒体库", "ItemRemovedWithName": "{0} 已从媒体库中移除", - "LabelIpAddressValue": "Ip 地址:{0}", + "LabelIpAddressValue": "IP 地址:{0}", "LabelRunningTimeValue": "运行时间:{0}", "Latest": "最新", "MessageApplicationUpdated": "Jellyfin 服务器已更新", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", + "MessageApplicationUpdatedTo": "Jellyfin Server 版本已更新为 {0}", "MessageNamedServerConfigurationUpdatedWithValue": "服务器配置 {0} 部分已更新", "MessageServerConfigurationUpdated": "服务器配置已更新", "MixedContent": "混合内容", "Movies": "电影", "Music": "音乐", "MusicVideos": "音乐视频", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "季 {0}", + "NameInstallFailed": "{0} 安装失败", + "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", + "NewVersionIsAvailable": "Jellyfin Server 有新版本可以下载。", "NotificationOptionApplicationUpdateAvailable": "有可用的应用程序更新", "NotificationOptionApplicationUpdateInstalled": "应用程序更新已安装", "NotificationOptionAudioPlayback": "音频开始播放", @@ -70,16 +69,15 @@ "ProviderValue": "提供商:{0}", "ScheduledTaskFailedWithName": "{0} 已失败", "ScheduledTaskStartedWithName": "{0} 已开始", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", + "ServerNameNeedsToBeRestarted": "{0} 需要重新启动", "Shows": "节目", "Songs": "歌曲", "StartupEmbyServerIsLoading": "Jellyfin 服务器加载中。请稍后再试。", "SubtitleDownloadFailureForItem": "为 {0} 下载字幕失败", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "已为 {0} 下载了字幕", + "SubtitleDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的字幕", "Sync": "同步", "System": "系统", - "TvShows": "电视节目", + "TvShows": "电视剧", "User": "用户", "UserCreatedWithName": "用户 {0} 已创建", "UserDeletedWithName": "用户 {0} 已删除", @@ -89,9 +87,38 @@ "UserOnlineFromDevice": "{0} 在线,来自 {1}", "UserPasswordChangedWithName": "已为用户 {0} 更改密码", "UserPolicyUpdatedWithName": "用户协议已经被更新为 {0}", - "UserStartedPlayingItemWithValues": "{0} 已开始播放 {1}", - "UserStoppedPlayingItemWithValues": "{0} 已停止播放 {1}", - "ValueHasBeenAddedToLibrary": "{0} has been added to your media library", + "UserStartedPlayingItemWithValues": "{0} 已在 {2} 上开始播放 {1}", + "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}", + "ValueHasBeenAddedToLibrary": "{0} 已添加至您的媒体库中", "ValueSpecialEpisodeName": "特典 - {0}", - "VersionNumber": "版本 {0}" + "VersionNumber": "版本 {0}", + "TaskUpdatePluginsDescription": "为已设置为自动更新的插件下载和安装更新。", + "TaskRefreshPeople": "刷新人员", + "TasksChannelsCategory": "互联网频道", + "TasksLibraryCategory": "媒体库", + "TaskDownloadMissingSubtitlesDescription": "根据元数据设置在互联网上搜索缺少的字幕。", + "TaskDownloadMissingSubtitles": "下载缺少的字幕", + "TaskRefreshChannelsDescription": "刷新互联网频道信息。", + "TaskRefreshChannels": "刷新频道", + "TaskCleanTranscodeDescription": "删除存在超过 1 天的转码文件。", + "TaskCleanTranscode": "清理转码目录", + "TaskUpdatePlugins": "更新插件", + "TaskRefreshPeopleDescription": "更新媒体库中演员和导演的元数据。", + "TaskCleanLogsDescription": "删除存在超过 {0} 天的的日志文件。", + "TaskCleanLogs": "清理日志目录", + "TaskRefreshLibraryDescription": "扫描你的媒体库以获取新文件并刷新元数据。", + "TaskRefreshLibrary": "扫描媒体库", + "TaskRefreshChapterImagesDescription": "为包含章节的视频提取缩略图。", + "TaskRefreshChapterImages": "提取章节图片", + "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。", + "TaskCleanCache": "清理缓存目录", + "TasksApplicationCategory": "应用程序", + "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 387fc2b92..1cc97bc27 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -1,97 +1,124 @@ { - "Albums": "Albums", - "AppDeviceValues": "App: {0}, Device: {1}", - "Application": "Application", - "Artists": "Artists", - "AuthenticationSucceededWithUserName": "{0} successfully authenticated", - "Books": "Books", - "CameraImageUploadedFrom": "A new camera image has been uploaded from {0}", - "Channels": "Channels", - "ChapterNameValue": "Chapter {0}", - "Collections": "Collections", - "DeviceOfflineWithName": "{0} has disconnected", - "DeviceOnlineWithName": "{0} is connected", - "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", - "Favorites": "Favorites", - "Folders": "Folders", - "Genres": "Genres", - "HeaderAlbumArtists": "Album Artists", - "HeaderCameraUploads": "Camera Uploads", - "HeaderContinueWatching": "Continue Watching", - "HeaderFavoriteAlbums": "Favorite Albums", - "HeaderFavoriteArtists": "Favorite Artists", - "HeaderFavoriteEpisodes": "Favorite Episodes", - "HeaderFavoriteShows": "Favorite Shows", - "HeaderFavoriteSongs": "Favorite Songs", - "HeaderLiveTV": "Live TV", - "HeaderNextUp": "Next Up", - "HeaderRecordingGroups": "Recording Groups", - "HomeVideos": "Home videos", - "Inherit": "Inherit", - "ItemAddedWithName": "{0} was added to the library", - "ItemRemovedWithName": "{0} was removed from the library", - "LabelIpAddressValue": "Ip address: {0}", - "LabelRunningTimeValue": "Running time: {0}", - "Latest": "Latest", - "MessageApplicationUpdated": "Jellyfin Server has been updated", - "MessageApplicationUpdatedTo": "Jellyfin Server has been updated to {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server configuration section {0} has been updated", - "MessageServerConfigurationUpdated": "Server configuration has been updated", - "MixedContent": "Mixed content", - "Movies": "Movies", - "Music": "Music", - "MusicVideos": "Music videos", - "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", - "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", - "NotificationOptionApplicationUpdateAvailable": "Application update available", - "NotificationOptionApplicationUpdateInstalled": "Application update installed", - "NotificationOptionAudioPlayback": "Audio playback started", - "NotificationOptionAudioPlaybackStopped": "Audio playback stopped", - "NotificationOptionCameraImageUploaded": "Camera image uploaded", - "NotificationOptionInstallationFailed": "Installation failure", - "NotificationOptionNewLibraryContent": "New content added", - "NotificationOptionPluginError": "Plugin failure", - "NotificationOptionPluginInstalled": "Plugin installed", - "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", - "Plugin": "Plugin", - "PluginInstalledWithName": "{0} was installed", - "PluginUninstalledWithName": "{0} was uninstalled", - "PluginUpdatedWithName": "{0} was updated", - "ProviderValue": "Provider: {0}", - "ScheduledTaskFailedWithName": "{0} failed", - "ScheduledTaskStartedWithName": "{0} started", - "ServerNameNeedsToBeRestarted": "{0} needs to be restarted", - "Shows": "Shows", - "Songs": "Songs", - "StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.", + "Albums": "專輯", + "AppDeviceValues": "程式: {0}, 設備: {1}", + "Application": "應用程式", + "Artists": "藝人", + "AuthenticationSucceededWithUserName": "{0} 授權成功", + "Books": "圖書", + "CameraImageUploadedFrom": "{0} 成功上傳一張新相片", + "Channels": "頻道", + "ChapterNameValue": "章節 {0}", + "Collections": "合輯", + "DeviceOfflineWithName": "{0} 已經斷開連結", + "DeviceOnlineWithName": "{0} 已經連接", + "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗", + "Favorites": "我的最愛", + "Folders": "資料夾", + "Genres": "風格", + "HeaderAlbumArtists": "專輯藝人", + "HeaderContinueWatching": "繼續觀看", + "HeaderFavoriteAlbums": "最愛專輯", + "HeaderFavoriteArtists": "最愛的藝人", + "HeaderFavoriteEpisodes": "最愛的劇集", + "HeaderFavoriteShows": "最愛的節目", + "HeaderFavoriteSongs": "最愛的歌曲", + "HeaderLiveTV": "電視直播", + "HeaderNextUp": "接下來", + "HeaderRecordingGroups": "錄製組", + "HomeVideos": "家庭影片", + "Inherit": "繼承", + "ItemAddedWithName": "{0} 已添加至媒體庫", + "ItemRemovedWithName": "{0} 已從媒體庫移除", + "LabelIpAddressValue": "IP 地址: {0}", + "LabelRunningTimeValue": "運行時間: {0}", + "Latest": "最新", + "MessageApplicationUpdated": "Jellyfin 伺服器已更新", + "MessageApplicationUpdatedTo": "Jellyfin 伺服器已更新至 {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 已更新", + "MessageServerConfigurationUpdated": "伺服器設定已經更新", + "MixedContent": "混合內容", + "Movies": "電影", + "Music": "音樂", + "MusicVideos": "音樂影片", + "NameInstallFailed": "{0} 安裝失敗", + "NameSeasonNumber": "第 {0} 季", + "NameSeasonUnknown": "未知季數", + "NewVersionIsAvailable": "新版本的 Jellyfin 伺服器可供下載。", + "NotificationOptionApplicationUpdateAvailable": "有可用的應用程式更新", + "NotificationOptionApplicationUpdateInstalled": "應用程式已更新", + "NotificationOptionAudioPlayback": "開始播放音頻", + "NotificationOptionAudioPlaybackStopped": "已停止播放音頻", + "NotificationOptionCameraImageUploaded": "相片已上傳", + "NotificationOptionInstallationFailed": "安裝失敗", + "NotificationOptionNewLibraryContent": "已添加新内容", + "NotificationOptionPluginError": "擴充元件錯誤", + "NotificationOptionPluginInstalled": "擴充元件已安裝", + "NotificationOptionPluginUninstalled": "擴充元件已移除", + "NotificationOptionPluginUpdateInstalled": "擴充元件更新已安裝", + "NotificationOptionServerRestartRequired": "伺服器需要重啓", + "NotificationOptionTaskFailed": "計劃任務失敗", + "NotificationOptionUserLockedOut": "用家已鎖定", + "NotificationOptionVideoPlayback": "開始播放視頻", + "NotificationOptionVideoPlaybackStopped": "已停止播放視頻", + "Photos": "相片", + "Playlists": "播放清單", + "Plugin": "插件", + "PluginInstalledWithName": "已安裝 {0}", + "PluginUninstalledWithName": "已移除 {0}", + "PluginUpdatedWithName": "已更新 {0}", + "ProviderValue": "提供者: {0}", + "ScheduledTaskFailedWithName": "{0} 任務失敗", + "ScheduledTaskStartedWithName": "{0} 任務開始", + "ServerNameNeedsToBeRestarted": "{0} 需要重啓", + "Shows": "節目", + "Songs": "歌曲", + "StartupEmbyServerIsLoading": "Jellyfin 伺服器載入中,請稍後再試。", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}", - "SubtitlesDownloadedForItem": "Subtitles downloaded for {0}", - "Sync": "Sync", - "System": "System", - "TvShows": "TV Shows", - "User": "User", - "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}", - "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": "Special - {0}", - "VersionNumber": "版本{0}" + "SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕", + "Sync": "同步", + "System": "系統", + "TvShows": "電視節目", + "User": "使用者", + "UserCreatedWithName": "使用者 {0} 已創建", + "UserDeletedWithName": "使用者 {0} 已移除", + "UserDownloadingItemWithValues": "{0} 正在下載 {1}", + "UserLockedOutWithName": "使用者 {0} 已被鎖定", + "UserOfflineFromDevice": "{0} 已從 {1} 斷開", + "UserOnlineFromDevice": "{0} 已連綫,來自 {1}", + "UserPasswordChangedWithName": "使用者 {0} 的密碼已變更", + "UserPolicyUpdatedWithName": "使用者協議已更新為 {0}", + "UserStartedPlayingItemWithValues": "{0} 正在 {2} 上播放 {1}", + "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}", + "ValueHasBeenAddedToLibrary": "{0} 已添加到你的媒體庫", + "ValueSpecialEpisodeName": "特典 - {0}", + "VersionNumber": "版本{0}", + "TaskDownloadMissingSubtitles": "下載遺失的字幕", + "TaskUpdatePlugins": "更新插件", + "TasksApplicationCategory": "應用程式", + "TaskRefreshLibraryDescription": "掃描媒體庫以查找新文件並刷新metadata。", + "TasksMaintenanceCategory": "維護", + "TaskDownloadMissingSubtitlesDescription": "根據metadata配置在互聯網上搜索缺少的字幕。", + "TaskRefreshChannelsDescription": "刷新互聯網頻道信息。", + "TaskRefreshChannels": "刷新頻道", + "TaskCleanTranscodeDescription": "刪除超過一天的轉碼文件。", + "TaskCleanTranscode": "清理轉碼目錄", + "TaskUpdatePluginsDescription": "下載並安裝配置為自動更新的插件的更新。", + "TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的metadata。", + "TaskCleanLogsDescription": "刪除超過{0}天的日誌文件。", + "TaskCleanLogs": "清理日誌目錄", + "TaskRefreshLibrary": "掃描媒體庫", + "TaskRefreshChapterImagesDescription": "為帶有章節的視頻創建縮略圖。", + "TaskRefreshChapterImages": "提取章節圖像", + "TaskCleanCacheDescription": "刪除系統不再需要的緩存文件。", + "TaskCleanCache": "清理緩存目錄", + "TasksChannelsCategory": "互聯網頻道", + "TasksLibraryCategory": "庫", + "TaskRefreshPeople": "刷新人物", + "TaskCleanActivityLog": "清理活動記錄", + "Undefined": "未定義", + "Forced": "強制", + "Default": "預設", + "TaskOptimizeDatabaseDescription": "壓縮數據庫並截斷可用空間。在掃描媒體庫或執行其他數據庫的修改後運行此任務可能會提高性能。", + "TaskOptimizeDatabase": "最佳化數據庫", + "TaskCleanActivityLogDescription": "刪除早於設定時間的日誌記錄。" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json new file mode 100644 index 000000000..585d81450 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -0,0 +1,123 @@ +{ + "Albums": "專輯", + "AppDeviceValues": "App:{0},裝置:{1}", + "Application": "應用程式", + "Artists": "演出者", + "AuthenticationSucceededWithUserName": "{0} 成功授權", + "Books": "圖書", + "CameraImageUploadedFrom": "{0} 已經成功上傳一張相片", + "Channels": "頻道", + "ChapterNameValue": "章節 {0}", + "Collections": "合輯", + "DeviceOfflineWithName": "{0} 已經斷線", + "DeviceOnlineWithName": "{0} 已經連線", + "FailedLoginAttemptWithUserName": "來自使用者 {0} 的失敗登入", + "Favorites": "我的最愛", + "Folders": "資料夾", + "Genres": "風格", + "HeaderAlbumArtists": "專輯演出者", + "HeaderContinueWatching": "繼續觀賞", + "HeaderFavoriteAlbums": "最愛專輯", + "HeaderFavoriteArtists": "最愛演出者", + "HeaderFavoriteEpisodes": "最愛影集", + "HeaderFavoriteShows": "最愛節目", + "HeaderFavoriteSongs": "最愛歌曲", + "HeaderLiveTV": "電視直播", + "HeaderNextUp": "接下來", + "HomeVideos": "家庭影片", + "ItemAddedWithName": "{0} 已新增至媒體庫", + "ItemRemovedWithName": "{0} 已從媒體庫移除", + "LabelIpAddressValue": "IP 位址:{0}", + "LabelRunningTimeValue": "運行時間:{0}", + "Latest": "最新", + "MessageApplicationUpdated": "Jellyfin Server 已經更新", + "MessageApplicationUpdatedTo": "Jellyfin Server 已經更新至 {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 部分已經更新", + "MessageServerConfigurationUpdated": "伺服器設定已經更新", + "MixedContent": "混合內容", + "Movies": "電影", + "Music": "音樂", + "MusicVideos": "音樂錄影帶", + "NameInstallFailed": "{0} 安裝失敗", + "NameSeasonNumber": "第 {0} 季", + "NameSeasonUnknown": "未知季數", + "NewVersionIsAvailable": "新版本的 Jellyfin Server 軟體已經可供下載。", + "NotificationOptionApplicationUpdateAvailable": "有可用的應用程式更新", + "NotificationOptionApplicationUpdateInstalled": "軟體更新已安裝", + "NotificationOptionAudioPlayback": "音樂開始播放", + "NotificationOptionAudioPlaybackStopped": "音樂停止播放", + "NotificationOptionCameraImageUploaded": "相機相片已上傳", + "NotificationOptionInstallationFailed": "安裝失敗", + "NotificationOptionNewLibraryContent": "已新增新內容", + "NotificationOptionPluginError": "外掛安裝失敗", + "NotificationOptionPluginInstalled": "外掛已安裝", + "NotificationOptionPluginUninstalled": "外掛已移除", + "NotificationOptionPluginUpdateInstalled": "外掛已更新", + "NotificationOptionServerRestartRequired": "伺服器需要重新啟動", + "NotificationOptionTaskFailed": "排程任務失敗", + "NotificationOptionUserLockedOut": "使用者已鎖定", + "NotificationOptionVideoPlayback": "影片開始播放", + "NotificationOptionVideoPlaybackStopped": "影片停止播放", + "Photos": "相片", + "Playlists": "播放清單", + "Plugin": "外掛", + "PluginInstalledWithName": "{0} 已安裝", + "PluginUninstalledWithName": "{0} 已移除", + "PluginUpdatedWithName": "{0} 已更新", + "ProviderValue": "提供商: {0}", + "ScheduledTaskFailedWithName": "排程任務 {0} 已失敗", + "ScheduledTaskStartedWithName": "排程任務 {0} 已開始", + "ServerNameNeedsToBeRestarted": "伺服器 {0} 需要重新啟動", + "Shows": "節目", + "Songs": "歌曲", + "StartupEmbyServerIsLoading": "Jellyfin Server正在啟動,請稍後再試一次。", + "Sync": "同步", + "System": "系統", + "TvShows": "電視節目", + "User": "使用者", + "UserCreatedWithName": "使用者 {0} 已建立", + "UserDeletedWithName": "使用者 {0} 已移除", + "UserDownloadingItemWithValues": "使用者 {0} 正在下載 {1}", + "UserLockedOutWithName": "使用者 {0} 已鎖定", + "UserOfflineFromDevice": "使用者 {0} 已從 {1} 斷線", + "UserOnlineFromDevice": "使用者 {0} 已從 {1} 連線", + "UserPasswordChangedWithName": "使用者 {0} 的密碼已變更", + "UserPolicyUpdatedWithName": "使用者條約已更新為 {0}", + "UserStartedPlayingItemWithValues": "{0}正在使用 {2} 播放 {1}", + "UserStoppedPlayingItemWithValues": "{0} 已停止在 {2} 播放 {1}", + "ValueHasBeenAddedToLibrary": "{0} 已新增至您的媒體庫", + "ValueSpecialEpisodeName": "特典 - {0}", + "VersionNumber": "版本 {0}", + "HeaderRecordingGroups": "錄製組", + "Inherit": "繼承", + "SubtitleDownloadFailureFromForItem": "無法為 {1} 從 {0} 下載字幕", + "TaskDownloadMissingSubtitlesDescription": "在網路上透過中繼資料搜尋遺失的字幕。", + "TaskDownloadMissingSubtitles": "下載遺失的字幕", + "TaskRefreshChannels": "重新整理頻道", + "TaskUpdatePlugins": "更新外掛", + "TaskRefreshPeople": "刷新用戶", + "TaskCleanLogsDescription": "刪除超過 {0} 天的舊紀錄檔。", + "TaskCleanLogs": "清空紀錄資料夾", + "TaskRefreshLibraryDescription": "重新掃描媒體庫的新檔案並更新描述資料。", + "TaskRefreshLibrary": "重新掃描媒體庫", + "TaskRefreshChapterImages": "擷取章節圖片", + "TaskCleanCacheDescription": "刪除系統已不需要的快取。", + "TaskCleanCache": "清除快取資料夾", + "TasksLibraryCategory": "媒體庫", + "TaskRefreshChannelsDescription": "重新整理網路頻道資料。", + "TaskCleanTranscodeDescription": "刪除超過一天的轉碼檔案。", + "TaskCleanTranscode": "清除轉碼資料夾", + "TaskUpdatePluginsDescription": "為設置自動更新的外掛下載並安裝更新。", + "TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的中繼資料。", + "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。", + "TasksChannelsCategory": "網路頻道", + "TasksApplicationCategory": "應用程式", + "TasksMaintenanceCategory": "維護", + "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。", + "TaskCleanActivityLog": "清除活動紀錄", + "Undefined": "未定義的", + "Forced": "強制", + "Default": "原本", + "TaskOptimizeDatabaseDescription": "縮小資料庫並釋放可用空間。在掃描資料庫或進行資料庫相關的更動後使用此功能會增加效能。", + "TaskOptimizeDatabase": "最佳化資料庫" +} diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 31217730b..03919197e 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -3,268 +3,199 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; using System.Reflection; -using System.Text; +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.Extensions; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Localization { /// <summary> - /// Class LocalizationManager + /// Class LocalizationManager. /// </summary> public class LocalizationManager : ILocalizationManager { - /// <summary> - /// The _configuration manager - /// </summary> - private readonly IServerConfigurationManager _configurationManager; + 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" }; - /// <summary> - /// The us culture - /// </summary> - private static readonly CultureInfo UsCulture = new CultureInfo("en-US"); + private readonly IServerConfigurationManager _configurationManager; + private readonly ILogger<LocalizationManager> _logger; private readonly Dictionary<string, Dictionary<string, ParentalRating>> _allParentalRatings = new Dictionary<string, Dictionary<string, ParentalRating>>(StringComparer.OrdinalIgnoreCase); - private readonly IFileSystem _fileSystem; - private readonly IJsonSerializer _jsonSerializer; - private readonly ILogger _logger; - private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly; + private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = + new ConcurrentDictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase); + + 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="fileSystem">The file system.</param> - /// <param name="jsonSerializer">The json serializer.</param> + /// <param name="logger">The logger.</param> public LocalizationManager( IServerConfigurationManager configurationManager, - IFileSystem fileSystem, - IJsonSerializer jsonSerializer, - ILoggerFactory loggerFactory) + ILogger<LocalizationManager> logger) { _configurationManager = configurationManager; - _fileSystem = fileSystem; - _jsonSerializer = jsonSerializer; - _logger = loggerFactory.CreateLogger(nameof(LocalizationManager)); + _logger = logger; } + /// <summary> + /// Loads all resources into memory. + /// </summary> + /// <returns><see cref="Task" />.</returns> public async Task LoadAll() { - const string ratingsResource = "Emby.Server.Implementations.Localization.Ratings."; - - Directory.CreateDirectory(LocalizationPath); - - var existingFiles = GetRatingsFiles(LocalizationPath).Select(Path.GetFileName); - // Extract from the assembly foreach (var resource in _assembly.GetManifestResourceNames()) { - if (!resource.StartsWith(ratingsResource)) + if (!resource.StartsWith(RatingsPath, StringComparison.Ordinal)) { continue; } - string filename = "ratings-" + resource.Substring(ratingsResource.Length); - - if (existingFiles.Contains(filename)) - { - continue; - } + string countryCode = resource.Substring(RatingsPath.Length, 2); + var dict = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase); - using (var stream = _assembly.GetManifestResourceStream(resource)) + 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 target = Path.Combine(LocalizationPath, filename); - _logger.LogInformation("Extracting ratings to {0}", target); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } - using (var fs = _fileSystem.GetFileStream(target, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + 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)); + } +#if DEBUG + else { - await stream.CopyToAsync(fs); + _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); } +#endif } - } - foreach (var file in GetRatingsFiles(LocalizationPath)) - { - await LoadRatings(file); + _allParentalRatings[countryCode] = dict; } - LoadAdditionalRatings(); - - await LoadCultures(); - } - - private void LoadAdditionalRatings() - { - LoadRatings("au", new[] - { - new ParentalRating("AU-G", 1), - new ParentalRating("AU-PG", 5), - new ParentalRating("AU-M", 6), - new ParentalRating("AU-MA15+", 7), - new ParentalRating("AU-M15+", 8), - new ParentalRating("AU-R18+", 9), - new ParentalRating("AU-X18+", 10), - new ParentalRating("AU-RC", 11) - }); - - LoadRatings("be", new[] - { - new ParentalRating("BE-AL", 1), - new ParentalRating("BE-MG6", 2), - new ParentalRating("BE-6", 3), - new ParentalRating("BE-9", 5), - new ParentalRating("BE-12", 6), - new ParentalRating("BE-16", 8) - }); - - LoadRatings("de", new[] - { - new ParentalRating("DE-0", 1), - new ParentalRating("FSK-0", 1), - new ParentalRating("DE-6", 5), - new ParentalRating("FSK-6", 5), - new ParentalRating("DE-12", 7), - new ParentalRating("FSK-12", 7), - new ParentalRating("DE-16", 8), - new ParentalRating("FSK-16", 8), - new ParentalRating("DE-18", 9), - new ParentalRating("FSK-18", 9) - }); - - LoadRatings("ru", new[] - { - new ParentalRating("RU-0+", 1), - new ParentalRating("RU-6+", 3), - new ParentalRating("RU-12+", 7), - new ParentalRating("RU-16+", 9), - new ParentalRating("RU-18+", 10) - }); + await LoadCultures().ConfigureAwait(false); } - private void LoadRatings(string country, ParentalRating[] ratings) - { - _allParentalRatings[country] = ratings.ToDictionary(i => i.Name); - } - - private IEnumerable<string> GetRatingsFiles(string directory) - => _fileSystem.GetFilePaths(directory, false) - .Where(i => string.Equals(Path.GetExtension(i), ".csv", StringComparison.OrdinalIgnoreCase)) - .Where(i => Path.GetFileName(i).StartsWith("ratings-", StringComparison.OrdinalIgnoreCase)); - - /// <summary> - /// Gets the localization path. - /// </summary> - /// <value>The localization path.</value> - public string LocalizationPath - => Path.Combine(_configurationManager.ApplicationPaths.ProgramDataPath, "localization"); - - public string NormalizeFormKD(string text) - => text.Normalize(NormalizationForm.FormKD); - - private CultureDto[] _cultures; - /// <summary> /// Gets the cultures. /// </summary> - /// <returns>IEnumerable{CultureDto}.</returns> - public CultureDto[] GetCultures() + /// <returns><see cref="IEnumerable{CultureDto}" />.</returns> + public IEnumerable<CultureDto> GetCultures() => _cultures; private async Task LoadCultures() { List<CultureDto> list = new List<CultureDto>(); - const string path = "Emby.Server.Implementations.Localization.iso6392.txt"; - - using (var stream = _assembly.GetManifestResourceStream(path)) - 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(); + 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])) + { + threeletterNames = new[] { parts[0] }; + } + else { - 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], parts[1] }; } + + list.Add(new CultureDto + { + DisplayName = name, + Name = name, + ThreeLetterISOLanguageNames = threeletterNames, + TwoLetterISOLanguageName = twoCharName + }); } } - _cultures = list.ToArray(); + _cultures = list; } - 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)); + /// <inheritdoc /> + 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; + } - /// <summary> - /// Gets the countries. - /// </summary> - /// <returns>IEnumerable{CountryInfo}.</returns> - public Task<CountryInfo[]> GetCountries() - => _jsonSerializer.DeserializeFromStreamAsync<CountryInfo[]>( - _assembly.GetManifestResourceStream("Emby.Server.Implementations.Localization.countries.json")); + /// <inheritdoc /> + public IEnumerable<CountryInfo> GetCountries() + { + 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}'"); + } - /// <summary> - /// Gets the parental ratings. - /// </summary> - /// <returns>IEnumerable{ParentalRating}.</returns> + /// <inheritdoc /> public IEnumerable<ParentalRating> GetParentalRatings() => GetParentalRatingsDictionary().Values; /// <summary> /// Gets the parental ratings dictionary. /// </summary> - /// <returns>Dictionary{System.StringParentalRating}.</returns> + /// <returns><see cref="Dictionary{String, ParentalRating}" />.</returns> private Dictionary<string, ParentalRating> GetParentalRatingsDictionary() { var countryCode = _configurationManager.Configuration.MetadataCountryCode; @@ -274,66 +205,24 @@ 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> /// Gets the ratings. /// </summary> /// <param name="countryCode">The country code.</param> - private Dictionary<string, ParentalRating> GetRatings(string countryCode) + /// <returns>The ratings.</returns> + private Dictionary<string, ParentalRating>? GetRatings(string countryCode) { _allParentalRatings.TryGetValue(countryCode, out var value); return value; } - /// <summary> - /// Loads the ratings. - /// </summary> - /// <param name="file">The file.</param> - /// <returns>Dictionary{System.StringParentalRating}.</returns> - private async Task LoadRatings(string file) - { - Dictionary<string, ParentalRating> dict - = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase); - - using (var str = File.OpenRead(file)) - using (var reader = new StreamReader(str)) - { - string line; - while ((line = await reader.ReadLineAsync()) != null) - { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - string[] parts = line.Split(','); - if (parts.Length == 2 - && int.TryParse(parts[1], NumberStyles.Integer, UsCulture, out var value)) - { - dict.Add(parts[0], (new ParentalRating { Name = parts[0], Value = value })); - } -#if DEBUG - else - { - _logger.LogWarning("Misformed line in {Path}", file); - } -#endif - } - } - - var countryCode = Path.GetFileNameWithoutExtension(file).Split('-')[1]; - - _allParentalRatings[countryCode] = dict; - } - - private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated" }; - - /// <summary> - /// Gets the rating level. - /// </summary> + /// <inheritdoc /> public int? GetRatingLevel(string rating) { if (string.IsNullOrEmpty(rating)) @@ -341,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; } @@ -351,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; } @@ -366,14 +255,14 @@ namespace Emby.Server.Implementations.Localization } // Try splitting by : to handle "Germany: FSK 18" - var index = rating.IndexOf(':'); + 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()); } } @@ -381,30 +270,20 @@ namespace Emby.Server.Implementations.Localization return null; } - 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); } + /// <inheritdoc /> public string GetLocalizedString(string phrase, string culture) { if (string.IsNullOrEmpty(culture)) { culture = _configurationManager.Configuration.UICulture; } + if (string.IsNullOrEmpty(culture)) { culture = DefaultCulture; @@ -420,23 +299,19 @@ namespace Emby.Server.Implementations.Localization return phrase; } - private const string DefaultCulture = "en-US"; - - private readonly ConcurrentDictionary<string, Dictionary<string, string>> _dictionaries = - new ConcurrentDictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase); - - public Dictionary<string, string> GetLocalizationDictionary(string culture) + private Dictionary<string, string> GetLocalizationDictionary(string culture) { if (string.IsNullOrEmpty(culture)) { throw new ArgumentNullException(nameof(culture)); } - const string prefix = "Core"; - var key = prefix + culture; + const string Prefix = "Core"; - return _dictionaries.GetOrAdd(key, - f => GetDictionary(prefix, culture, DefaultCulture + ".json").GetAwaiter().GetResult()); + return _dictionaries.GetOrAdd( + 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) @@ -450,30 +325,31 @@ namespace Emby.Server.Implementations.Localization var namespaceName = GetType().Namespace + "." + prefix; - await CopyInto(dictionary, namespaceName + "." + baseFilename); - await CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture)); + await CopyInto(dictionary, namespaceName + "." + baseFilename).ConfigureAwait(false); + await CopyInto(dictionary, namespaceName + "." + GetResourceFilename(culture)).ConfigureAwait(false); return dictionary; } 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); + _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]; } } @@ -493,45 +369,46 @@ namespace Emby.Server.Implementations.Localization return culture + ".json"; } - public LocalizationOption[] GetLocalizationOptions() - => new LocalizationOption[] - { - new LocalizationOption("Arabic", "ar"), - new LocalizationOption("Bulgarian (Bulgaria)", "bg-BG"), - new LocalizationOption("Catalan", "ca"), - new LocalizationOption("Chinese Simplified", "zh-CN"), - new LocalizationOption("Chinese Traditional", "zh-TW"), - new LocalizationOption("Croatian", "hr"), - new LocalizationOption("Czech", "cs"), - new LocalizationOption("Danish", "da"), - new LocalizationOption("Dutch", "nl"), - new LocalizationOption("English (United Kingdom)", "en-GB"), - new LocalizationOption("English (United States)", "en-US"), - new LocalizationOption("French", "fr"), - new LocalizationOption("French (Canada)", "fr-CA"), - new LocalizationOption("German", "de"), - new LocalizationOption("Greek", "el"), - new LocalizationOption("Hebrew", "he"), - new LocalizationOption("Hungarian", "hu"), - new LocalizationOption("Italian", "it"), - new LocalizationOption("Kazakh", "kk"), - new LocalizationOption("Korean", "ko"), - new LocalizationOption("Lithuanian", "lt-LT"), - new LocalizationOption("Malay", "ms"), - new LocalizationOption("Norwegian Bokmål", "nb"), - new LocalizationOption("Persian", "fa"), - new LocalizationOption("Polish", "pl"), - new LocalizationOption("Portuguese (Brazil)", "pt-BR"), - new LocalizationOption("Portuguese (Portugal)", "pt-PT"), - new LocalizationOption("Russian", "ru"), - new LocalizationOption("Slovak", "sk"), - new LocalizationOption("Slovenian (Slovenia)", "sl-SI"), - new LocalizationOption("Spanish", "es"), - new LocalizationOption("Spanish (Argentina)", "es-AR"), - new LocalizationOption("Spanish (Mexico)", "es-MX"), - new LocalizationOption("Swedish", "sv"), - new LocalizationOption("Swiss German", "gsw"), - new LocalizationOption("Turkish", "tr") - }; + /// <inheritdoc /> + public IEnumerable<LocalizationOption> GetLocalizationOptions() + { + yield return new LocalizationOption("Arabic", "ar"); + yield return new LocalizationOption("Bulgarian (Bulgaria)", "bg-BG"); + yield return new LocalizationOption("Catalan", "ca"); + yield return new LocalizationOption("Chinese Simplified", "zh-CN"); + yield return new LocalizationOption("Chinese Traditional", "zh-TW"); + yield return new LocalizationOption("Croatian", "hr"); + yield return new LocalizationOption("Czech", "cs"); + yield return new LocalizationOption("Danish", "da"); + yield return new LocalizationOption("Dutch", "nl"); + yield return new LocalizationOption("English (United Kingdom)", "en-GB"); + yield return new LocalizationOption("English (United States)", "en-US"); + yield return new LocalizationOption("French", "fr"); + yield return new LocalizationOption("French (Canada)", "fr-CA"); + yield return new LocalizationOption("German", "de"); + yield return new LocalizationOption("Greek", "el"); + yield return new LocalizationOption("Hebrew", "he"); + yield return new LocalizationOption("Hungarian", "hu"); + yield return new LocalizationOption("Italian", "it"); + yield return new LocalizationOption("Kazakh", "kk"); + yield return new LocalizationOption("Korean", "ko"); + yield return new LocalizationOption("Lithuanian", "lt-LT"); + yield return new LocalizationOption("Malay", "ms"); + yield return new LocalizationOption("Norwegian Bokmål", "nb"); + yield return new LocalizationOption("Persian", "fa"); + yield return new LocalizationOption("Polish", "pl"); + yield return new LocalizationOption("Portuguese (Brazil)", "pt-BR"); + yield return new LocalizationOption("Portuguese (Portugal)", "pt-PT"); + yield return new LocalizationOption("Russian", "ru"); + yield return new LocalizationOption("Slovak", "sk"); + yield return new LocalizationOption("Slovenian (Slovenia)", "sl-SI"); + yield return new LocalizationOption("Spanish", "es"); + yield return new LocalizationOption("Spanish (Argentina)", "es-AR"); + yield return new LocalizationOption("Spanish (Mexico)", "es-MX"); + 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/Ratings/au.csv b/Emby.Server.Implementations/Localization/Ratings/au.csv new file mode 100644 index 000000000..940375e26 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/au.csv @@ -0,0 +1,8 @@ +AU-G,1 +AU-PG,5 +AU-M,6 +AU-MA15+,7 +AU-M15+,8 +AU-R18+,9 +AU-X18+,10 +AU-RC,11 diff --git a/Emby.Server.Implementations/Localization/Ratings/be.csv b/Emby.Server.Implementations/Localization/Ratings/be.csv new file mode 100644 index 000000000..d3937caf7 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/be.csv @@ -0,0 +1,6 @@ +BE-AL,1 +BE-MG6,2 +BE-6,3 +BE-9,5 +BE-12,6 +BE-16,8 diff --git a/Emby.Server.Implementations/Localization/Ratings/de.csv b/Emby.Server.Implementations/Localization/Ratings/de.csv new file mode 100644 index 000000000..f944a140d --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/de.csv @@ -0,0 +1,10 @@ +DE-0,1 +FSK-0,1 +DE-6,5 +FSK-6,5 +DE-12,7 +FSK-12,7 +DE-16,8 +FSK-16,8 +DE-18,9 +FSK-18,9 diff --git a/Emby.Server.Implementations/Localization/Ratings/ru.csv b/Emby.Server.Implementations/Localization/Ratings/ru.csv new file mode 100644 index 000000000..1bc94affd --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/ru.csv @@ -0,0 +1,5 @@ +RU-0+,1 +RU-6+,3 +RU-12+,7 +RU-16+,9 +RU-18+,10 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 e68046f6d..ac6606d39 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Globalization; @@ -10,30 +14,35 @@ 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 { public class EncodingManager : IEncodingManager { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly IFileSystem _fileSystem; - private readonly ILogger _logger; + private readonly ILogger<EncodingManager> _logger; private readonly IMediaEncoder _encoder; private readonly IChapterManager _chapterManager; private readonly ILibraryManager _libraryManager; + /// <summary> + /// The first chapter ticks. + /// </summary> + private static readonly long _firstChapterTicks = TimeSpan.FromSeconds(15).Ticks; + public EncodingManager( + ILogger<EncodingManager> logger, IFileSystem fileSystem, - ILoggerFactory loggerFactory, IMediaEncoder encoder, - IChapterManager chapterManager, ILibraryManager libraryManager) + IChapterManager chapterManager, + ILibraryManager libraryManager) { + _logger = logger; _fileSystem = fileSystem; - _logger = loggerFactory.CreateLogger(nameof(EncodingManager)); _encoder = encoder; _chapterManager = chapterManager; _libraryManager = libraryManager; @@ -73,16 +82,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; @@ -97,12 +96,7 @@ namespace Emby.Server.Implementations.MediaEncoder return video.DefaultVideoStreamIndex.HasValue; } - /// <summary> - /// The first chapter ticks - /// </summary> - private static readonly long FirstChapterTicks = TimeSpan.FromSeconds(15).Ticks; - - public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, List<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken) + public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken) { if (!IsEligibleForChapterImageExtraction(video)) { @@ -135,26 +129,30 @@ namespace Emby.Server.Implementations.MediaEncoder try { // 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 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, protocol, 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 { _fileSystem.DeleteFile(tempFile); } - catch + catch (IOException ex) { - + _logger.LogError(ex, "Error deleting temporary chapter image encoding file {Path}", tempFile); } chapter.ImagePath = path; @@ -163,7 +161,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; } @@ -184,7 +182,7 @@ namespace Emby.Server.Implementations.MediaEncoder if (saveChapters && changesMade) { - _chapterManager.SaveChapters(video.Id.ToString(), chapters); + _chapterManager.SaveChapters(video.Id, chapters); } DeleteDeadImages(currentImages, chapters); @@ -194,23 +192,26 @@ namespace Emby.Server.Implementations.MediaEncoder private string GetChapterImagePath(Video video, long chapterPositionTicks) { - var filename = video.DateModified.Ticks.ToString(_usCulture) + "_" + chapterPositionTicks.ToString(_usCulture) + ".jpg"; + var filename = video.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) + "_" + chapterPositionTicks.ToString(CultureInfo.InvariantCulture) + ".jpg"; return Path.Combine(GetChapterImagesPath(video), filename); } - private static List<string> GetSavedChapterImages(Video video, IDirectoryService directoryService) + private static IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService) { var path = GetChapterImagesPath(video); + if (!Directory.Exists(path)) + { + return Array.Empty<string>(); + } try { - return directoryService.GetFilePaths(path) - .ToList(); + return directoryService.GetFilePaths(path); } catch (IOException) { - return new List<string>(); + return Array.Empty<string>(); } } @@ -223,7 +224,7 @@ namespace Emby.Server.Implementations.MediaEncoder foreach (var image in deadImages) { - _logger.LogDebug("Deleting dead chapter image {path}", image); + _logger.LogDebug("Deleting dead chapter image {Path}", image); try { @@ -231,7 +232,7 @@ namespace Emby.Server.Implementations.MediaEncoder } catch (IOException ex) { - _logger.LogError(ex, "Error deleting {path}.", image); + _logger.LogError(ex, "Error deleting {Path}.", image); } } } diff --git a/Emby.Server.Implementations/Net/DisposableManagedObjectBase.cs b/Emby.Server.Implementations/Net/DisposableManagedObjectBase.cs deleted file mode 100644 index 304b44565..000000000 --- a/Emby.Server.Implementations/Net/DisposableManagedObjectBase.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; - -namespace Emby.Server.Implementations.Net -{ - /// <summary> - /// Correclty implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceities not on the interface such as an <see cref="IsDisposed"/> property. - /// </summary> - public abstract class DisposableManagedObjectBase : IDisposable - { - - #region Public Methods - - /// <summary> - /// Override this method and dispose any objects you own the lifetime of if disposing is true; - /// </summary> - /// <param name="disposing">True if managed objects should be disposed, if false, only unmanaged resources should be released.</param> - protected abstract void Dispose(bool disposing); - - - //TODO Remove and reimplement using the IsDisposed property directly. - /// <summary> - /// Throws an <see cref="ObjectDisposedException"/> if the <see cref="IsDisposed"/> property is true. - /// </summary> - /// <seealso cref="IsDisposed"/> - /// <exception cref="ObjectDisposedException">Thrown if the <see cref="IsDisposed"/> property is true.</exception> - /// <seealso cref="Dispose()"/> - protected virtual void ThrowIfDisposed() - { - if (IsDisposed) throw new ObjectDisposedException(GetType().Name); - } - - #endregion - - #region Public Properties - - /// <summary> - /// Sets or returns a boolean indicating whether or not this instance has been disposed. - /// </summary> - /// <seealso cref="Dispose()"/> - public bool IsDisposed - { - get; - private set; - } - - #endregion - - #region IDisposable Members - - /// <summary> - /// Disposes this object instance and all internally managed resources. - /// </summary> - /// <remarks> - /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behaviour of derived classes.</para> - /// </remarks> - /// <seealso cref="IsDisposed"/> - public void Dispose() - { - IsDisposed = true; - - Dispose(true); - } - - #endregion - } -} diff --git a/Emby.Server.Implementations/Net/IWebSocket.cs b/Emby.Server.Implementations/Net/IWebSocket.cs deleted file mode 100644 index 4671de07c..000000000 --- a/Emby.Server.Implementations/Net/IWebSocket.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - -namespace Emby.Server.Implementations.Net -{ - /// <summary> - /// Interface IWebSocket - /// </summary> - public interface IWebSocket : IDisposable - { - /// <summary> - /// Occurs when [closed]. - /// </summary> - event EventHandler<EventArgs> Closed; - - /// <summary> - /// Gets or sets the state. - /// </summary> - /// <value>The state.</value> - WebSocketState State { get; } - - /// <summary> - /// Gets or sets the receive action. - /// </summary> - /// <value>The receive action.</value> - Action<byte[]> OnReceiveBytes { get; set; } - - /// <summary> - /// Sends the async. - /// </summary> - /// <param name="bytes">The bytes.</param> - /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task SendAsync(byte[] bytes, bool endOfMessage, CancellationToken cancellationToken); - - /// <summary> - /// Sends the asynchronous. - /// </summary> - /// <param name="text">The text.</param> - /// <param name="endOfMessage">if set to <c>true</c> [end of message].</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task SendAsync(string text, bool endOfMessage, CancellationToken cancellationToken); - } - - public interface IMemoryWebSocket - { - Action<Memory<byte>, int> OnReceiveMemoryBytes { get; set; } - } -} diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 6beb14f55..6d0c8731e 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -1,84 +1,28 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; -using System.IO; using System.Net; using System.Net.Sockets; -using Emby.Server.Implementations.Networking; using MediaBrowser.Model.Net; -using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Net { public class SocketFactory : ISocketFactory { - // THIS IS A LINKED FILE - SHARED AMONGST MULTIPLE PLATFORMS - // Be careful to check any changes compile and work for all platform projects it is shared in. - - // Not entirely happy with this. Would have liked to have done something more generic/reusable, - // but that wasn't really the point so kept to YAGNI principal for now, even if the - // interfaces are a bit ugly, specific and make assumptions. - - public ISocket CreateTcpSocket(IpAddressInfo remoteAddress, int remotePort) - { - if (remotePort < 0) throw new ArgumentException("remotePort cannot be less than zero.", nameof(remotePort)); - - var addressFamily = remoteAddress.AddressFamily == IpAddressFamily.InterNetwork - ? AddressFamily.InterNetwork - : AddressFamily.InterNetworkV6; - - var retVal = new Socket(addressFamily, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); - - try - { - retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - catch (SocketException) - { - // This is not supported on all operating systems (qnap) - } - - try - { - return new UdpSocket(retVal, new IpEndPointInfo(remoteAddress, remotePort)); - } - catch - { - if (retVal != null) - retVal.Dispose(); - - throw; - } - } - - /// <summary> - /// Creates a new UDP acceptSocket and binds it to the specified local port. - /// </summary> - /// <param name="localPort">An integer specifying the local port to bind the acceptSocket to.</param> - public ISocket CreateUdpSocket(int localPort) + /// <inheritdoc /> + public ISocket CreateUdpBroadcastSocket(int localPort) { - if (localPort < 0) throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); - - var retVal = new Socket(AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); - try + if (localPort < 0) { - retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - return new UdpSocket(retVal, localPort, IPAddress.Any); + throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); } - catch - { - if (retVal != null) - retVal.Dispose(); - - throw; - } - } - - public ISocket CreateUdpBroadcastSocket(int localPort) - { - if (localPort < 0) throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); var retVal = new Socket(AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); try { + retVal.EnableBroadcast = true; retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, 1); @@ -86,54 +30,60 @@ namespace Emby.Server.Implementations.Net } catch { - if (retVal != null) - retVal.Dispose(); + retVal?.Dispose(); throw; } } - /// <summary> - /// Creates a new UDP acceptSocket that is a member of the SSDP multicast local admin group and binds it to the specified local port. - /// </summary> - /// <returns>An implementation of the <see cref="ISocket"/> interface used by RSSDP components to perform acceptSocket operations.</returns> - public ISocket CreateSsdpUdpSocket(IpAddressInfo localIpAddress, int localPort) + /// <inheritdoc /> + public ISocket CreateSsdpUdpSocket(IPAddress localIp, int localPort) { - if (localPort < 0) throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); + if (localPort < 0) + { + throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); + } var retVal = new Socket(AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); try { + retVal.EnableBroadcast = true; retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 4); - var localIp = NetworkManager.ToIPAddress(localIpAddress); - retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(IPAddress.Parse("239.255.255.250"), localIp)); return new UdpSocket(retVal, localPort, localIp); } catch { - if (retVal != null) - retVal.Dispose(); + retVal?.Dispose(); throw; } } - /// <summary> - /// Creates a new UDP acceptSocket that is a member of the specified multicast IP address, and binds it to the specified local port. - /// </summary> - /// <param name="ipAddress">The multicast IP address to make the acceptSocket a member of.</param> - /// <param name="multicastTimeToLive">The multicast time to live value for the acceptSocket.</param> - /// <param name="localPort">The number of the local port to bind to.</param> - /// <returns></returns> + /// <inheritdoc /> public ISocket CreateUdpMulticastSocket(string ipAddress, int multicastTimeToLive, int localPort) { - if (ipAddress == null) throw new ArgumentNullException(nameof(ipAddress)); - if (ipAddress.Length == 0) throw new ArgumentException("ipAddress cannot be an empty string.", nameof(ipAddress)); - if (multicastTimeToLive <= 0) throw new ArgumentException("multicastTimeToLive cannot be zero or less.", nameof(multicastTimeToLive)); - if (localPort < 0) throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); + if (ipAddress == null) + { + throw new ArgumentNullException(nameof(ipAddress)); + } + + if (ipAddress.Length == 0) + { + throw new ArgumentException("ipAddress cannot be an empty string.", nameof(ipAddress)); + } + + if (multicastTimeToLive <= 0) + { + throw new ArgumentException("multicastTimeToLive cannot be zero or less.", nameof(multicastTimeToLive)); + } + + if (localPort < 0) + { + throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); + } var retVal = new Socket(AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); @@ -144,7 +94,6 @@ namespace Emby.Server.Implementations.Net } catch (SocketException) { - } try @@ -155,12 +104,12 @@ namespace Emby.Server.Implementations.Net } catch (SocketException) { - } try { - //retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, true); + retVal.EnableBroadcast = true; + // retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, true); retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, multicastTimeToLive); var localIp = IPAddress.Any; @@ -172,87 +121,10 @@ namespace Emby.Server.Implementations.Net } catch { - if (retVal != null) - retVal.Dispose(); + retVal?.Dispose(); throw; } } - - public Stream CreateNetworkStream(ISocket socket, bool ownsSocket) - { - var netSocket = (UdpSocket)socket; - - return new SocketStream(netSocket.Socket, ownsSocket); - } } - - public class SocketStream : Stream - { - private readonly Socket _socket; - - public SocketStream(Socket socket, bool ownsSocket) - { - _socket = socket; - } - - public override void Flush() - { - } - - public override bool CanRead => true; - - public override bool CanSeek => false; - - public override bool CanWrite => true; - - public override long Length => throw new NotImplementedException(); - - public override long Position - { - get => throw new NotImplementedException(); - set => throw new NotImplementedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - _socket.Send(buffer, offset, count, SocketFlags.None); - } - - public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state) - { - return _socket.BeginSend(buffer, offset, count, SocketFlags.None, callback, state); - } - - public override void EndWrite(IAsyncResult asyncResult) - { - _socket.EndSend(asyncResult); - } - - public override void SetLength(long value) - { - throw new NotImplementedException(); - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotImplementedException(); - } - - public override int Read(byte[] buffer, int offset, int count) - { - return _socket.Receive(buffer, offset, count, SocketFlags.None); - } - - public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state) - { - return _socket.BeginReceive(buffer, offset, count, SocketFlags.None, callback, state); - } - - public override int EndRead(IAsyncResult asyncResult) - { - return _socket.EndReceive(asyncResult); - } - } - } diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs index d48855486..0c451ccb6 100644 --- a/Emby.Server.Implementations/Net/UdpSocket.cs +++ b/Emby.Server.Implementations/Net/UdpSocket.cs @@ -1,9 +1,12 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; -using Emby.Server.Implementations.Networking; using MediaBrowser.Model.Net; namespace Emby.Server.Implementations.Net @@ -11,12 +14,9 @@ namespace Emby.Server.Implementations.Net // THIS IS A LINKED FILE - SHARED AMONGST MULTIPLE PLATFORMS // Be careful to check any changes compile and work for all platform projects it is shared in. - public sealed class UdpSocket : DisposableManagedObjectBase, ISocket + public sealed class UdpSocket : ISocket, IDisposable { - private Socket _Socket; - private int _LocalPort; - - public Socket Socket => _Socket; + private readonly int _localPort; private readonly SocketAsyncEventArgs _receiveSocketAsyncEventArgs = new SocketAsyncEventArgs() { @@ -28,34 +28,56 @@ namespace Emby.Server.Implementations.Net SocketFlags = SocketFlags.None }; + private Socket _socket; + private bool _disposed = false; private TaskCompletionSource<SocketReceiveResult> _currentReceiveTaskCompletionSource; private TaskCompletionSource<int> _currentSendTaskCompletionSource; public UdpSocket(Socket socket, int localPort, IPAddress ip) { - if (socket == null) throw new ArgumentNullException(nameof(socket)); + if (socket == null) + { + throw new ArgumentNullException(nameof(socket)); + } - _Socket = socket; - _LocalPort = localPort; - LocalIPAddress = NetworkManager.ToIpAddressInfo(ip); + _socket = socket; + _localPort = localPort; + LocalIPAddress = ip; - _Socket.Bind(new IPEndPoint(ip, _LocalPort)); + _socket.Bind(new IPEndPoint(ip, _localPort)); InitReceiveSocketAsyncEventArgs(); } + public UdpSocket(Socket socket, IPEndPoint endPoint) + { + if (socket == null) + { + throw new ArgumentNullException(nameof(socket)); + } + + _socket = socket; + _socket.Connect(endPoint); + + InitReceiveSocketAsyncEventArgs(); + } + + public Socket Socket => _socket; + + public IPAddress LocalIPAddress { get; } + private void InitReceiveSocketAsyncEventArgs() { var receiveBuffer = new byte[8192]; _receiveSocketAsyncEventArgs.SetBuffer(receiveBuffer, 0, receiveBuffer.Length); - _receiveSocketAsyncEventArgs.Completed += _receiveSocketAsyncEventArgs_Completed; + _receiveSocketAsyncEventArgs.Completed += OnReceiveSocketAsyncEventArgsCompleted; var sendBuffer = new byte[8192]; _sendSocketAsyncEventArgs.SetBuffer(sendBuffer, 0, sendBuffer.Length); - _sendSocketAsyncEventArgs.Completed += _sendSocketAsyncEventArgs_Completed; + _sendSocketAsyncEventArgs.Completed += OnSendSocketAsyncEventArgsCompleted; } - private void _receiveSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e) + private void OnReceiveSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e) { var tcs = _currentReceiveTaskCompletionSource; if (tcs != null) @@ -68,7 +90,7 @@ namespace Emby.Server.Implementations.Net { Buffer = e.Buffer, ReceivedBytes = e.BytesTransferred, - RemoteEndPoint = ToIpEndPointInfo(e.RemoteEndPoint as IPEndPoint), + RemoteEndPoint = e.RemoteEndPoint as IPEndPoint, LocalIPAddress = LocalIPAddress }); } @@ -79,7 +101,7 @@ namespace Emby.Server.Implementations.Net } } - private void _sendSocketAsyncEventArgs_Completed(object sender, SocketAsyncEventArgs e) + private void OnSendSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e) { var tcs = _currentSendTaskCompletionSource; if (tcs != null) @@ -97,36 +119,20 @@ namespace Emby.Server.Implementations.Net } } - public UdpSocket(Socket socket, IpEndPointInfo endPoint) - { - if (socket == null) throw new ArgumentNullException(nameof(socket)); - - _Socket = socket; - _Socket.Connect(NetworkManager.ToIPEndPoint(endPoint)); - - InitReceiveSocketAsyncEventArgs(); - } - - public IpAddressInfo LocalIPAddress - { - get; - private set; - } - public IAsyncResult BeginReceive(byte[] buffer, int offset, int count, AsyncCallback callback) { ThrowIfDisposed(); EndPoint receivedFromEndPoint = new IPEndPoint(IPAddress.Any, 0); - return _Socket.BeginReceiveFrom(buffer, offset, count, SocketFlags.None, ref receivedFromEndPoint, callback, buffer); + return _socket.BeginReceiveFrom(buffer, offset, count, SocketFlags.None, ref receivedFromEndPoint, callback, buffer); } public int Receive(byte[] buffer, int offset, int count) { ThrowIfDisposed(); - return _Socket.Receive(buffer, 0, buffer.Length, SocketFlags.None); + return _socket.Receive(buffer, 0, buffer.Length, SocketFlags.None); } public SocketReceiveResult EndReceive(IAsyncResult result) @@ -136,14 +142,14 @@ namespace Emby.Server.Implementations.Net var sender = new IPEndPoint(IPAddress.Any, 0); var remoteEndPoint = (EndPoint)sender; - var receivedBytes = _Socket.EndReceiveFrom(result, ref remoteEndPoint); + var receivedBytes = _socket.EndReceiveFrom(result, ref remoteEndPoint); var buffer = (byte[])result.AsyncState; return new SocketReceiveResult { ReceivedBytes = receivedBytes, - RemoteEndPoint = ToIpEndPointInfo((IPEndPoint)remoteEndPoint), + RemoteEndPoint = (IPEndPoint)remoteEndPoint, Buffer = buffer, LocalIPAddress = LocalIPAddress }; @@ -185,16 +191,7 @@ namespace Emby.Server.Implementations.Net return taskCompletion.Task; } - public Task<SocketReceiveResult> ReceiveAsync(CancellationToken cancellationToken) - { - ThrowIfDisposed(); - - var buffer = new byte[8192]; - - return ReceiveAsync(buffer, 0, buffer.Length, cancellationToken); - } - - public Task SendToAsync(byte[] buffer, int offset, int size, IpEndPointInfo endPoint, CancellationToken cancellationToken) + public Task SendToAsync(byte[] buffer, int offset, int bytes, IPEndPoint endPoint, CancellationToken cancellationToken) { ThrowIfDisposed(); @@ -217,7 +214,7 @@ namespace Emby.Server.Implementations.Net } }; - var result = BeginSendTo(buffer, offset, size, endPoint, new AsyncCallback(callback), null); + var result = BeginSendTo(buffer, offset, bytes, endPoint, new AsyncCallback(callback), null); if (result.CompletedSynchronously) { @@ -230,51 +227,47 @@ namespace Emby.Server.Implementations.Net return taskCompletion.Task; } - public IAsyncResult BeginSendTo(byte[] buffer, int offset, int size, IpEndPointInfo endPoint, AsyncCallback callback, object state) + public IAsyncResult BeginSendTo(byte[] buffer, int offset, int size, IPEndPoint endPoint, AsyncCallback callback, object state) { ThrowIfDisposed(); - var ipEndPoint = NetworkManager.ToIPEndPoint(endPoint); - - return _Socket.BeginSendTo(buffer, offset, size, SocketFlags.None, ipEndPoint, callback, state); + return _socket.BeginSendTo(buffer, offset, size, SocketFlags.None, endPoint, callback, state); } public int EndSendTo(IAsyncResult result) { ThrowIfDisposed(); - return _Socket.EndSendTo(result); + return _socket.EndSendTo(result); } - protected override void Dispose(bool disposing) + private void ThrowIfDisposed() { - if (disposing) + if (_disposed) { - var socket = _Socket; - if (socket != null) - socket.Dispose(); - - var tcs = _currentReceiveTaskCompletionSource; - if (tcs != null) - { - tcs.TrySetCanceled(); - } - var sendTcs = _currentSendTaskCompletionSource; - if (sendTcs != null) - { - sendTcs.TrySetCanceled(); - } + throw new ObjectDisposedException(nameof(UdpSocket)); } } - private static IpEndPointInfo ToIpEndPointInfo(IPEndPoint endpoint) + /// <inheritdoc /> + public void Dispose() { - if (endpoint == null) + if (_disposed) { - return null; + return; } - return NetworkManager.ToIpEndPointInfo(endpoint); + _socket?.Dispose(); + _receiveSocketAsyncEventArgs.Dispose(); + _sendSocketAsyncEventArgs.Dispose(); + _currentReceiveTaskCompletionSource?.TrySetCanceled(); + _currentSendTaskCompletionSource?.TrySetCanceled(); + + _socket = null; + _currentReceiveTaskCompletionSource = null; + _currentSendTaskCompletionSource = null; + + _disposed = true; } } } diff --git a/Emby.Server.Implementations/Net/WebSocketConnectEventArgs.cs b/Emby.Server.Implementations/Net/WebSocketConnectEventArgs.cs deleted file mode 100644 index 3ab8e854a..000000000 --- a/Emby.Server.Implementations/Net/WebSocketConnectEventArgs.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.Net -{ - public class WebSocketConnectEventArgs : EventArgs - { - /// <summary> - /// Gets or sets the URL. - /// </summary> - /// <value>The URL.</value> - public string Url { get; set; } - /// <summary> - /// Gets or sets the query string. - /// </summary> - /// <value>The query string.</value> - public QueryParamCollection QueryString { get; set; } - /// <summary> - /// Gets or sets the web socket. - /// </summary> - /// <value>The web socket.</value> - public IWebSocket WebSocket { get; set; } - /// <summary> - /// Gets or sets the endpoint. - /// </summary> - /// <value>The endpoint.</value> - public string Endpoint { get; set; } - } -} diff --git a/Emby.Server.Implementations/Networking/IPNetwork/BigIntegerExt.cs b/Emby.Server.Implementations/Networking/IPNetwork/BigIntegerExt.cs deleted file mode 100644 index e4e944839..000000000 --- a/Emby.Server.Implementations/Networking/IPNetwork/BigIntegerExt.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using System.Text; - -namespace Emby.Server.Implementations.Networking.IPNetwork -{ - /// <summary> - /// Extension methods to convert <see cref="BigInteger"/> - /// instances to hexadecimal, octal, and binary strings. - /// </summary> - public static class BigIntegerExtensions - { - /// <summary> - /// Converts a <see cref="BigInteger"/> to a binary string. - /// </summary> - /// <param name="bigint">A <see cref="BigInteger"/>.</param> - /// <returns> - /// A <see cref="string"/> containing a binary - /// representation of the supplied <see cref="BigInteger"/>. - /// </returns> - public static string ToBinaryString(this BigInteger bigint) - { - var bytes = bigint.ToByteArray(); - var idx = bytes.Length - 1; - - // Create a StringBuilder having appropriate capacity. - var base2 = new StringBuilder(bytes.Length * 8); - - // Convert first byte to binary. - var binary = Convert.ToString(bytes[idx], 2); - - // Ensure leading zero exists if value is positive. - if (binary[0] != '0' && bigint.Sign == 1) - { - base2.Append('0'); - } - - // Append binary string to StringBuilder. - base2.Append(binary); - - // Convert remaining bytes adding leading zeros. - for (idx--; idx >= 0; idx--) - { - base2.Append(Convert.ToString(bytes[idx], 2).PadLeft(8, '0')); - } - - return base2.ToString(); - } - - /// <summary> - /// Converts a <see cref="BigInteger"/> to a hexadecimal string. - /// </summary> - /// <param name="bigint">A <see cref="BigInteger"/>.</param> - /// <returns> - /// A <see cref="string"/> containing a hexadecimal - /// representation of the supplied <see cref="BigInteger"/>. - /// </returns> - public static string ToHexadecimalString(this BigInteger bigint) - { - return bigint.ToString("X"); - } - - /// <summary> - /// Converts a <see cref="BigInteger"/> to a octal string. - /// </summary> - /// <param name="bigint">A <see cref="BigInteger"/>.</param> - /// <returns> - /// A <see cref="string"/> containing an octal - /// representation of the supplied <see cref="BigInteger"/>. - /// </returns> - public static string ToOctalString(this BigInteger bigint) - { - var bytes = bigint.ToByteArray(); - var idx = bytes.Length - 1; - - // Create a StringBuilder having appropriate capacity. - var base8 = new StringBuilder(((bytes.Length / 3) + 1) * 8); - - // Calculate how many bytes are extra when byte array is split - // into three-byte (24-bit) chunks. - var extra = bytes.Length % 3; - - // If no bytes are extra, use three bytes for first chunk. - if (extra == 0) - { - extra = 3; - } - - // Convert first chunk (24-bits) to integer value. - int int24 = 0; - for (; extra != 0; extra--) - { - int24 <<= 8; - int24 += bytes[idx--]; - } - - // Convert 24-bit integer to octal without adding leading zeros. - var octal = Convert.ToString(int24, 8); - - // Ensure leading zero exists if value is positive. - if (octal[0] != '0') - { - if (bigint.Sign == 1) - { - base8.Append('0'); - } - } - - // Append first converted chunk to StringBuilder. - base8.Append(octal); - - // Convert remaining 24-bit chunks, adding leading zeros. - for (; idx >= 0; idx -= 3) - { - int24 = (bytes[idx] << 16) + (bytes[idx - 1] << 8) + bytes[idx - 2]; - base8.Append(Convert.ToString(int24, 8).PadLeft(8, '0')); - } - - return base8.ToString(); - } - - /// <summary> - /// - /// Reverse a Positive BigInteger ONLY - /// Bitwise ~ operator - /// - /// Input : FF FF FF FF - /// Width : 4 - /// Result : 00 00 00 00 - /// - /// - /// Input : 00 00 00 00 - /// Width : 4 - /// Result : FF FF FF FF - /// - /// Input : FF FF FF FF - /// Width : 8 - /// Result : FF FF FF FF 00 00 00 00 - /// - /// - /// Input : 00 00 00 00 - /// Width : 8 - /// Result : FF FF FF FF FF FF FF FF - /// - /// </summary> - /// <param name="input"></param> - /// <param name="width"></param> - /// <returns></returns> - public static BigInteger PositiveReverse(this BigInteger input, int width) - { - - var result = new List<byte>(); - var bytes = input.ToByteArray(); - var work = new byte[width]; - Array.Copy(bytes, 0, work, 0, bytes.Length - 1); // Length -1 : positive BigInteger - - for (int i = 0; i < work.Length; i++) - { - result.Add((byte)(~work[i])); - } - result.Add(0); // positive BigInteger - return new BigInteger(result.ToArray()); - - } - } -} diff --git a/Emby.Server.Implementations/Networking/IPNetwork/IPAddressCollection.cs b/Emby.Server.Implementations/Networking/IPNetwork/IPAddressCollection.cs deleted file mode 100644 index a0c5f73af..000000000 --- a/Emby.Server.Implementations/Networking/IPNetwork/IPAddressCollection.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Net; -using System.Numerics; - -namespace Emby.Server.Implementations.Networking.IPNetwork -{ - public class IPAddressCollection : IEnumerable<IPAddress>, IEnumerator<IPAddress> - { - - private IPNetwork _ipnetwork; - private BigInteger _enumerator; - - internal IPAddressCollection(IPNetwork ipnetwork) - { - this._ipnetwork = ipnetwork; - this._enumerator = -1; - } - - - #region Count, Array, Enumerator - - public BigInteger Count => this._ipnetwork.Total; - - public IPAddress this[BigInteger i] - { - get - { - if (i >= this.Count) - { - throw new ArgumentOutOfRangeException(nameof(i)); - } - byte width = this._ipnetwork.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? (byte)32 : (byte)128; - var ipn = this._ipnetwork.Subnet(width); - return ipn[i].Network; - } - } - - #endregion - - #region IEnumerable Members - - IEnumerator<IPAddress> IEnumerable<IPAddress>.GetEnumerator() - { - return this; - } - - IEnumerator IEnumerable.GetEnumerator() - { - return this; - } - - #region IEnumerator<IPNetwork> Members - - public IPAddress Current => this[this._enumerator]; - - #endregion - - #region IDisposable Members - - public void Dispose() - { - // nothing to dispose - return; - } - - #endregion - - #region IEnumerator Members - - object IEnumerator.Current => this.Current; - - public bool MoveNext() - { - this._enumerator++; - if (this._enumerator >= this.Count) - { - return false; - } - return true; - - } - - public void Reset() - { - this._enumerator = -1; - } - - #endregion - - #endregion - } -} diff --git a/Emby.Server.Implementations/Networking/IPNetwork/IPNetwork.cs b/Emby.Server.Implementations/Networking/IPNetwork/IPNetwork.cs deleted file mode 100644 index d6de61c0c..000000000 --- a/Emby.Server.Implementations/Networking/IPNetwork/IPNetwork.cs +++ /dev/null @@ -1,2008 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Numerics; -using System.Text.RegularExpressions; - -namespace Emby.Server.Implementations.Networking.IPNetwork -{ - /// <summary> - /// IP Network utility class. - /// Use IPNetwork.Parse to create instances. - /// </summary> - public class IPNetwork : IComparable<IPNetwork> - { - - #region properties - - //private uint _network; - private BigInteger _ipaddress; - private AddressFamily _family; - //private uint _netmask; - //private uint _broadcast; - //private uint _firstUsable; - //private uint _lastUsable; - //private uint _usable; - private byte _cidr; - - #endregion - - #region accessors - - private BigInteger _network - { - get - { - var uintNetwork = this._ipaddress & this._netmask; - return uintNetwork; - } - } - - /// <summary> - /// Network address - /// </summary> - public IPAddress Network => IPNetwork.ToIPAddress(this._network, this._family); - - /// <summary> - /// Address Family - /// </summary> - public AddressFamily AddressFamily => this._family; - - private BigInteger _netmask => IPNetwork.ToUint(this._cidr, this._family); - - /// <summary> - /// Netmask - /// </summary> - public IPAddress Netmask => IPNetwork.ToIPAddress(this._netmask, this._family); - - private BigInteger _broadcast - { - get - { - - int width = this._family == System.Net.Sockets.AddressFamily.InterNetwork ? 4 : 16; - var uintBroadcast = this._network + this._netmask.PositiveReverse(width); - return uintBroadcast; - } - } - - /// <summary> - /// Broadcast address - /// </summary> - public IPAddress Broadcast - { - get - { - if (this._family == System.Net.Sockets.AddressFamily.InterNetworkV6) - { - return null; - } - return IPNetwork.ToIPAddress(this._broadcast, this._family); - } - } - - /// <summary> - /// First usable IP adress in Network - /// </summary> - public IPAddress FirstUsable - { - get - { - var fisrt = this._family == System.Net.Sockets.AddressFamily.InterNetworkV6 - ? this._network - : (this.Usable <= 0) ? this._network : this._network + 1; - return IPNetwork.ToIPAddress(fisrt, this._family); - } - } - - /// <summary> - /// Last usable IP adress in Network - /// </summary> - public IPAddress LastUsable - { - get - { - var last = this._family == System.Net.Sockets.AddressFamily.InterNetworkV6 - ? this._broadcast - : (this.Usable <= 0) ? this._network : this._broadcast - 1; - return IPNetwork.ToIPAddress(last, this._family); - } - } - - /// <summary> - /// Number of usable IP adress in Network - /// </summary> - public BigInteger Usable - { - get - { - - if (this._family == System.Net.Sockets.AddressFamily.InterNetworkV6) - { - return this.Total; - } - byte[] mask = new byte[] { 0xff, 0xff, 0xff, 0xff, 0x00 }; - var bmask = new BigInteger(mask); - var usableIps = (_cidr > 30) ? 0 : ((bmask >> _cidr) - 1); - return usableIps; - } - } - - /// <summary> - /// Number of IP adress in Network - /// </summary> - public BigInteger Total - { - get - { - - int max = this._family == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128; - var count = BigInteger.Pow(2, (max - _cidr)); - return count; - } - } - - - /// <summary> - /// The CIDR netmask notation - /// </summary> - public byte Cidr => this._cidr; - - #endregion - - #region constructor - -#if TRAVISCI - public -#else - internal -#endif - - IPNetwork(BigInteger ipaddress, AddressFamily family, byte cidr) - { - - int maxCidr = family == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128; - if (cidr > maxCidr) - { - throw new ArgumentOutOfRangeException(nameof(cidr)); - } - - this._ipaddress = ipaddress; - this._family = family; - this._cidr = cidr; - - } - - #endregion - - #region parsers - - /// <summary> - /// 192.168.168.100 - 255.255.255.0 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="netmask"></param> - /// <returns></returns> - public static IPNetwork Parse(string ipaddress, string netmask) - { - - IPNetwork ipnetwork = null; - IPNetwork.InternalParse(false, ipaddress, netmask, out ipnetwork); - return ipnetwork; - } - - /// <summary> - /// 192.168.168.100/24 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="cidr"></param> - /// <returns></returns> - public static IPNetwork Parse(string ipaddress, byte cidr) - { - - IPNetwork ipnetwork = null; - IPNetwork.InternalParse(false, ipaddress, cidr, out ipnetwork); - return ipnetwork; - - } - - /// <summary> - /// 192.168.168.100 255.255.255.0 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="netmask"></param> - /// <returns></returns> - public static IPNetwork Parse(IPAddress ipaddress, IPAddress netmask) - { - - IPNetwork ipnetwork = null; - IPNetwork.InternalParse(false, ipaddress, netmask, out ipnetwork); - return ipnetwork; - - } - - /// <summary> - /// 192.168.0.1/24 - /// 192.168.0.1 255.255.255.0 - /// - /// Network : 192.168.0.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.0.1 - /// End : 192.168.0.254 - /// Broadcast : 192.168.0.255 - /// </summary> - /// <param name="network"></param> - /// <returns></returns> - public static IPNetwork Parse(string network) - { - - IPNetwork ipnetwork = null; - IPNetwork.InternalParse(false, network, out ipnetwork); - return ipnetwork; - - } - - #endregion - - #region TryParse - - - - /// <summary> - /// 192.168.168.100 - 255.255.255.0 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="netmask"></param> - /// <returns></returns> - public static bool TryParse(string ipaddress, string netmask, out IPNetwork ipnetwork) - { - - IPNetwork ipnetwork2 = null; - IPNetwork.InternalParse(true, ipaddress, netmask, out ipnetwork2); - bool parsed = (ipnetwork2 != null); - ipnetwork = ipnetwork2; - return parsed; - - } - - - - /// <summary> - /// 192.168.168.100/24 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="cidr"></param> - /// <returns></returns> - public static bool TryParse(string ipaddress, byte cidr, out IPNetwork ipnetwork) - { - - IPNetwork ipnetwork2 = null; - IPNetwork.InternalParse(true, ipaddress, cidr, out ipnetwork2); - bool parsed = (ipnetwork2 != null); - ipnetwork = ipnetwork2; - return parsed; - - } - - /// <summary> - /// 192.168.0.1/24 - /// 192.168.0.1 255.255.255.0 - /// - /// Network : 192.168.0.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.0.1 - /// End : 192.168.0.254 - /// Broadcast : 192.168.0.255 - /// </summary> - /// <param name="network"></param> - /// <param name="ipnetwork"></param> - /// <returns></returns> - public static bool TryParse(string network, out IPNetwork ipnetwork) - { - - IPNetwork ipnetwork2 = null; - IPNetwork.InternalParse(true, network, out ipnetwork2); - bool parsed = (ipnetwork2 != null); - ipnetwork = ipnetwork2; - return parsed; - - } - - /// <summary> - /// 192.168.0.1/24 - /// 192.168.0.1 255.255.255.0 - /// - /// Network : 192.168.0.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.0.1 - /// End : 192.168.0.254 - /// Broadcast : 192.168.0.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="netmask"></param> - /// <param name="ipnetwork"></param> - /// <returns></returns> - public static bool TryParse(IPAddress ipaddress, IPAddress netmask, out IPNetwork ipnetwork) - { - - IPNetwork ipnetwork2 = null; - IPNetwork.InternalParse(true, ipaddress, netmask, out ipnetwork2); - bool parsed = (ipnetwork2 != null); - ipnetwork = ipnetwork2; - return parsed; - - } - - - #endregion - - #region InternalParse - - /// <summary> - /// 192.168.168.100 - 255.255.255.0 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="netmask"></param> - /// <returns></returns> - private static void InternalParse(bool tryParse, string ipaddress, string netmask, out IPNetwork ipnetwork) - { - - if (string.IsNullOrEmpty(ipaddress)) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(ipaddress)); - } - ipnetwork = null; - return; - } - - if (string.IsNullOrEmpty(netmask)) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(netmask)); - } - ipnetwork = null; - return; - } - - IPAddress ip = null; - bool ipaddressParsed = IPAddress.TryParse(ipaddress, out ip); - if (ipaddressParsed == false) - { - if (tryParse == false) - { - throw new ArgumentException("ipaddress"); - } - ipnetwork = null; - return; - } - - IPAddress mask = null; - bool netmaskParsed = IPAddress.TryParse(netmask, out mask); - if (netmaskParsed == false) - { - if (tryParse == false) - { - throw new ArgumentException("netmask"); - } - ipnetwork = null; - return; - } - - IPNetwork.InternalParse(tryParse, ip, mask, out ipnetwork); - } - - private static void InternalParse(bool tryParse, string network, out IPNetwork ipnetwork) - { - - if (string.IsNullOrEmpty(network)) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(network)); - } - ipnetwork = null; - return; - } - - network = Regex.Replace(network, @"[^0-9a-fA-F\.\/\s\:]+", ""); - network = Regex.Replace(network, @"\s{2,}", " "); - network = network.Trim(); - string[] args = network.Split(new char[] { ' ', '/' }); - byte cidr = 0; - if (args.Length == 1) - { - - if (IPNetwork.TryGuessCidr(args[0], out cidr)) - { - IPNetwork.InternalParse(tryParse, args[0], cidr, out ipnetwork); - return; - } - - if (tryParse == false) - { - throw new ArgumentException("network"); - } - ipnetwork = null; - return; - } - - if (byte.TryParse(args[1], out cidr)) - { - IPNetwork.InternalParse(tryParse, args[0], cidr, out ipnetwork); - return; - } - - IPNetwork.InternalParse(tryParse, args[0], args[1], out ipnetwork); - return; - - } - - - - /// <summary> - /// 192.168.168.100 255.255.255.0 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="netmask"></param> - /// <returns></returns> - private static void InternalParse(bool tryParse, IPAddress ipaddress, IPAddress netmask, out IPNetwork ipnetwork) - { - - if (ipaddress == null) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(ipaddress)); - } - ipnetwork = null; - return; - } - - if (netmask == null) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(netmask)); - } - ipnetwork = null; - return; - } - - var uintIpAddress = IPNetwork.ToBigInteger(ipaddress); - bool parsed = IPNetwork.TryToCidr(netmask, out var cidr2); - if (parsed == false) - { - if (tryParse == false) - { - throw new ArgumentException("netmask"); - } - ipnetwork = null; - return; - } - byte cidr = (byte)cidr2; - - var ipnet = new IPNetwork(uintIpAddress, ipaddress.AddressFamily, cidr); - ipnetwork = ipnet; - - return; - } - - - - /// <summary> - /// 192.168.168.100/24 - /// - /// Network : 192.168.168.0 - /// Netmask : 255.255.255.0 - /// Cidr : 24 - /// Start : 192.168.168.1 - /// End : 192.168.168.254 - /// Broadcast : 192.168.168.255 - /// </summary> - /// <param name="ipaddress"></param> - /// <param name="cidr"></param> - /// <returns></returns> - private static void InternalParse(bool tryParse, string ipaddress, byte cidr, out IPNetwork ipnetwork) - { - - if (string.IsNullOrEmpty(ipaddress)) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(ipaddress)); - } - ipnetwork = null; - return; - } - - - IPAddress ip = null; - bool ipaddressParsed = IPAddress.TryParse(ipaddress, out ip); - if (ipaddressParsed == false) - { - if (tryParse == false) - { - throw new ArgumentException("ipaddress"); - } - ipnetwork = null; - return; - } - - IPAddress mask = null; - bool parsedNetmask = IPNetwork.TryToNetmask(cidr, ip.AddressFamily, out mask); - if (parsedNetmask == false) - { - if (tryParse == false) - { - throw new ArgumentException("cidr"); - } - ipnetwork = null; - return; - } - - - IPNetwork.InternalParse(tryParse, ip, mask, out ipnetwork); - } - - #endregion - - #region converters - - #region ToUint - - /// <summary> - /// Convert an ipadress to decimal - /// 0.0.0.0 -> 0 - /// 0.0.1.0 -> 256 - /// </summary> - /// <param name="ipaddress"></param> - /// <returns></returns> - public static BigInteger ToBigInteger(IPAddress ipaddress) - { - IPNetwork.InternalToBigInteger(false, ipaddress, out var uintIpAddress); - return (BigInteger)uintIpAddress; - - } - - /// <summary> - /// Convert an ipadress to decimal - /// 0.0.0.0 -> 0 - /// 0.0.1.0 -> 256 - /// </summary> - /// <param name="ipaddress"></param> - /// <returns></returns> - public static bool TryToBigInteger(IPAddress ipaddress, out BigInteger? uintIpAddress) - { - IPNetwork.InternalToBigInteger(true, ipaddress, out var uintIpAddress2); - bool parsed = (uintIpAddress2 != null); - uintIpAddress = uintIpAddress2; - return parsed; - } - -#if TRAVISCI - public -#else - internal -#endif - static void InternalToBigInteger(bool tryParse, IPAddress ipaddress, out BigInteger? uintIpAddress) - { - - if (ipaddress == null) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(ipaddress)); - } - uintIpAddress = null; - return; - } - - byte[] bytes = ipaddress.GetAddressBytes(); - /// 20180217 lduchosal - /// code impossible to reach, GetAddressBytes returns either 4 or 16 bytes length addresses - /// if (bytes.Length != 4 && bytes.Length != 16) { - /// if (tryParse == false) { - /// throw new ArgumentException("bytes"); - /// } - /// uintIpAddress = null; - /// return; - /// } - - Array.Reverse(bytes); - var unsigned = new List<byte>(bytes); - unsigned.Add(0); - uintIpAddress = new BigInteger(unsigned.ToArray()); - return; - } - - - /// <summary> - /// Convert a cidr to BigInteger netmask - /// </summary> - /// <param name="cidr"></param> - /// <returns></returns> - public static BigInteger ToUint(byte cidr, AddressFamily family) - { - IPNetwork.InternalToBigInteger(false, cidr, family, out var uintNetmask); - return (BigInteger)uintNetmask; - } - - - /// <summary> - /// Convert a cidr to uint netmask - /// </summary> - /// <param name="cidr"></param> - /// <returns></returns> - public static bool TryToUint(byte cidr, AddressFamily family, out BigInteger? uintNetmask) - { - IPNetwork.InternalToBigInteger(true, cidr, family, out var uintNetmask2); - bool parsed = (uintNetmask2 != null); - uintNetmask = uintNetmask2; - return parsed; - } - - /// <summary> - /// Convert a cidr to uint netmask - /// </summary> - /// <param name="cidr"></param> - /// <returns></returns> -#if TRAVISCI - public -#else - internal -#endif - static void InternalToBigInteger(bool tryParse, byte cidr, AddressFamily family, out BigInteger? uintNetmask) - { - - if (family == AddressFamily.InterNetwork && cidr > 32) - { - if (tryParse == false) - { - throw new ArgumentOutOfRangeException(nameof(cidr)); - } - uintNetmask = null; - return; - } - - if (family == AddressFamily.InterNetworkV6 && cidr > 128) - { - if (tryParse == false) - { - throw new ArgumentOutOfRangeException(nameof(cidr)); - } - uintNetmask = null; - return; - } - - if (family != AddressFamily.InterNetwork - && family != AddressFamily.InterNetworkV6) - { - if (tryParse == false) - { - throw new NotSupportedException(family.ToString()); - } - uintNetmask = null; - return; - } - - if (family == AddressFamily.InterNetwork) - { - - uintNetmask = cidr == 0 ? 0 : 0xffffffff << (32 - cidr); - return; - } - - var mask = new BigInteger(new byte[] { - 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, - 0x00 - }); - - var masked = cidr == 0 ? 0 : mask << (128 - cidr); - byte[] m = masked.ToByteArray(); - byte[] bmask = new byte[17]; - int copy = m.Length > 16 ? 16 : m.Length; - Array.Copy(m, 0, bmask, 0, copy); - uintNetmask = new BigInteger(bmask); - - - } - - #endregion - - #region ToCidr - - /// <summary> - /// Convert netmask to CIDR - /// 255.255.255.0 -> 24 - /// 255.255.0.0 -> 16 - /// 255.0.0.0 -> 8 - /// </summary> - /// <param name="netmask"></param> - /// <returns></returns> - private static void InternalToCidr(bool tryParse, BigInteger netmask, AddressFamily family, out byte? cidr) - { - - if (!IPNetwork.InternalValidNetmask(netmask, family)) - { - if (tryParse == false) - { - throw new ArgumentException("netmask"); - } - cidr = null; - return; - } - - byte cidr2 = IPNetwork.BitsSet(netmask, family); - cidr = cidr2; - return; - - } - /// <summary> - /// Convert netmask to CIDR - /// 255.255.255.0 -> 24 - /// 255.255.0.0 -> 16 - /// 255.0.0.0 -> 8 - /// </summary> - /// <param name="netmask"></param> - /// <returns></returns> - public static byte ToCidr(IPAddress netmask) - { - IPNetwork.InternalToCidr(false, netmask, out var cidr); - return (byte)cidr; - } - - /// <summary> - /// Convert netmask to CIDR - /// 255.255.255.0 -> 24 - /// 255.255.0.0 -> 16 - /// 255.0.0.0 -> 8 - /// </summary> - /// <param name="netmask"></param> - /// <returns></returns> - public static bool TryToCidr(IPAddress netmask, out byte? cidr) - { - IPNetwork.InternalToCidr(true, netmask, out var cidr2); - bool parsed = (cidr2 != null); - cidr = cidr2; - return parsed; - } - - private static void InternalToCidr(bool tryParse, IPAddress netmask, out byte? cidr) - { - - if (netmask == null) - { - if (tryParse == false) - { - throw new ArgumentNullException(nameof(netmask)); - } - cidr = null; - return; - } - - bool parsed = IPNetwork.TryToBigInteger(netmask, out var uintNetmask2); - - /// 20180217 lduchosal - /// impossible to reach code. - /// if (parsed == false) { - /// if (tryParse == false) { - /// throw new ArgumentException("netmask"); - /// } - /// cidr = null; - /// return; - /// } - var uintNetmask = (BigInteger)uintNetmask2; - - IPNetwork.InternalToCidr(tryParse, uintNetmask, netmask.AddressFamily, out var cidr2); - cidr = cidr2; - - return; - - } - - - #endregion - - #region ToNetmask - - /// <summary> - /// Convert CIDR to netmask - /// 24 -> 255.255.255.0 - /// 16 -> 255.255.0.0 - /// 8 -> 255.0.0.0 - /// </summary> - /// <see cref="http://snipplr.com/view/15557/cidr-class-for-ipv4/"/> - /// <param name="cidr"></param> - /// <returns></returns> - public static IPAddress ToNetmask(byte cidr, AddressFamily family) - { - - IPAddress netmask = null; - IPNetwork.InternalToNetmask(false, cidr, family, out netmask); - return netmask; - } - - /// <summary> - /// Convert CIDR to netmask - /// 24 -> 255.255.255.0 - /// 16 -> 255.255.0.0 - /// 8 -> 255.0.0.0 - /// </summary> - /// <see cref="http://snipplr.com/view/15557/cidr-class-for-ipv4/"/> - /// <param name="cidr"></param> - /// <returns></returns> - public static bool TryToNetmask(byte cidr, AddressFamily family, out IPAddress netmask) - { - - IPAddress netmask2 = null; - IPNetwork.InternalToNetmask(true, cidr, family, out netmask2); - bool parsed = (netmask2 != null); - netmask = netmask2; - return parsed; - } - - -#if TRAVISCI - public -#else - internal -#endif - static void InternalToNetmask(bool tryParse, byte cidr, AddressFamily family, out IPAddress netmask) - { - - if (family != AddressFamily.InterNetwork - && family != AddressFamily.InterNetworkV6) - { - if (tryParse == false) - { - throw new ArgumentException("family"); - } - netmask = null; - return; - } - - /// 20180217 lduchosal - /// impossible to reach code, byte cannot be negative : - /// - /// if (cidr < 0) { - /// if (tryParse == false) { - /// throw new ArgumentOutOfRangeException("cidr"); - /// } - /// netmask = null; - /// return; - /// } - - int maxCidr = family == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128; - if (cidr > maxCidr) - { - if (tryParse == false) - { - throw new ArgumentOutOfRangeException(nameof(cidr)); - } - netmask = null; - return; - } - - var mask = IPNetwork.ToUint(cidr, family); - var netmask2 = IPNetwork.ToIPAddress(mask, family); - netmask = netmask2; - - return; - } - - #endregion - - #endregion - - #region utils - - #region BitsSet - - /// <summary> - /// Count bits set to 1 in netmask - /// </summary> - /// <see cref="http://stackoverflow.com/questions/109023/best-algorithm-to-count-the-number-of-set-bits-in-a-32-bit-integer"/> - /// <param name="netmask"></param> - /// <returns></returns> - private static byte BitsSet(BigInteger netmask, AddressFamily family) - { - - string s = netmask.ToBinaryString(); - return (byte)s.Replace("0", "") - .ToCharArray() - .Length; - - } - - - /// <summary> - /// Count bits set to 1 in netmask - /// </summary> - /// <param name="netmask"></param> - /// <returns></returns> - public static uint BitsSet(IPAddress netmask) - { - var uintNetmask = IPNetwork.ToBigInteger(netmask); - uint bits = IPNetwork.BitsSet(uintNetmask, netmask.AddressFamily); - return bits; - } - - #endregion - - #region ValidNetmask - - /// <summary> - /// return true if netmask is a valid netmask - /// 255.255.255.0, 255.0.0.0, 255.255.240.0, ... - /// </summary> - /// <see cref="http://www.actionsnip.com/snippets/tomo_atlacatl/calculate-if-a-netmask-is-valid--as2-"/> - /// <param name="netmask"></param> - /// <returns></returns> - public static bool ValidNetmask(IPAddress netmask) - { - - if (netmask == null) - { - throw new ArgumentNullException(nameof(netmask)); - } - var uintNetmask = IPNetwork.ToBigInteger(netmask); - bool valid = IPNetwork.InternalValidNetmask(uintNetmask, netmask.AddressFamily); - return valid; - } - -#if TRAVISCI - public -#else - internal -#endif - static bool InternalValidNetmask(BigInteger netmask, AddressFamily family) - { - - if (family != AddressFamily.InterNetwork - && family != AddressFamily.InterNetworkV6) - { - throw new ArgumentException("family"); - } - - var mask = family == AddressFamily.InterNetwork - ? new BigInteger(0x0ffffffff) - : new BigInteger(new byte[]{ - 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, - 0x00 - }); - - var neg = ((~netmask) & (mask)); - bool isNetmask = ((neg + 1) & neg) == 0; - return isNetmask; - - } - - #endregion - - #region ToIPAddress - - /// <summary> - /// Transform a uint ipaddress into IPAddress object - /// </summary> - /// <param name="ipaddress"></param> - /// <returns></returns> - public static IPAddress ToIPAddress(BigInteger ipaddress, AddressFamily family) - { - - int width = family == AddressFamily.InterNetwork ? 4 : 16; - byte[] bytes = ipaddress.ToByteArray(); - byte[] bytes2 = new byte[width]; - int copy = bytes.Length > width ? width : bytes.Length; - Array.Copy(bytes, 0, bytes2, 0, copy); - Array.Reverse(bytes2); - - byte[] sized = Resize(bytes2, family); - var ip = new IPAddress(sized); - return ip; - } - -#if TRAVISCI - public -#else - internal -#endif - static byte[] Resize(byte[] bytes, AddressFamily family) - { - - if (family != AddressFamily.InterNetwork - && family != AddressFamily.InterNetworkV6) - { - throw new ArgumentException("family"); - } - - int width = family == AddressFamily.InterNetwork ? 4 : 16; - - if (bytes.Length > width) - { - throw new ArgumentException("bytes"); - } - - byte[] result = new byte[width]; - Array.Copy(bytes, 0, result, 0, bytes.Length); - return result; - } - - #endregion - - #endregion - - #region contains - - /// <summary> - /// return true if ipaddress is contained in network - /// </summary> - /// <param name="ipaddress"></param> - /// <returns></returns> - public bool Contains(IPAddress ipaddress) - { - - if (ipaddress == null) - { - throw new ArgumentNullException(nameof(ipaddress)); - } - - if (AddressFamily != ipaddress.AddressFamily) - { - return false; - } - - var uintNetwork = _network; - var uintBroadcast = _broadcast; - var uintAddress = IPNetwork.ToBigInteger(ipaddress); - - bool contains = (uintAddress >= uintNetwork - && uintAddress <= uintBroadcast); - - return contains; - - } - - /// <summary> - /// return true is network2 is fully contained in network - /// </summary> - /// <param name="network2"></param> - /// <returns></returns> - public bool Contains(IPNetwork network2) - { - - if (network2 == null) - { - throw new ArgumentNullException(nameof(network2)); - } - - var uintNetwork = _network; - var uintBroadcast = _broadcast; - - var uintFirst = network2._network; - var uintLast = network2._broadcast; - - bool contains = (uintFirst >= uintNetwork - && uintLast <= uintBroadcast); - - return contains; - } - - #endregion - - #region overlap - - /// <summary> - /// return true is network2 overlap network - /// </summary> - /// <param name="network2"></param> - /// <returns></returns> - public bool Overlap(IPNetwork network2) - { - - if (network2 == null) - { - throw new ArgumentNullException(nameof(network2)); - } - - var uintNetwork = _network; - var uintBroadcast = _broadcast; - - var uintFirst = network2._network; - var uintLast = network2._broadcast; - - bool overlap = - (uintFirst >= uintNetwork && uintFirst <= uintBroadcast) - || (uintLast >= uintNetwork && uintLast <= uintBroadcast) - || (uintFirst <= uintNetwork && uintLast >= uintBroadcast) - || (uintFirst >= uintNetwork && uintLast <= uintBroadcast); - - return overlap; - } - - #endregion - - #region ToString - - public override string ToString() - { - return string.Format("{0}/{1}", this.Network, this.Cidr); - } - - #endregion - - #region IANA block - - private static readonly Lazy<IPNetwork> _iana_ablock_reserved = new Lazy<IPNetwork>(() => IPNetwork.Parse("10.0.0.0/8")); - private static readonly Lazy<IPNetwork> _iana_bblock_reserved = new Lazy<IPNetwork>(() => IPNetwork.Parse("172.16.0.0/12")); - private static readonly Lazy<IPNetwork> _iana_cblock_reserved = new Lazy<IPNetwork>(() => IPNetwork.Parse("192.168.0.0/16")); - - /// <summary> - /// 10.0.0.0/8 - /// </summary> - /// <returns></returns> - public static IPNetwork IANA_ABLK_RESERVED1 => _iana_ablock_reserved.Value; - - /// <summary> - /// 172.12.0.0/12 - /// </summary> - /// <returns></returns> - public static IPNetwork IANA_BBLK_RESERVED1 => _iana_bblock_reserved.Value; - - /// <summary> - /// 192.168.0.0/16 - /// </summary> - /// <returns></returns> - public static IPNetwork IANA_CBLK_RESERVED1 => _iana_cblock_reserved.Value; - - /// <summary> - /// return true if ipaddress is contained in - /// IANA_ABLK_RESERVED1, IANA_BBLK_RESERVED1, IANA_CBLK_RESERVED1 - /// </summary> - /// <param name="ipaddress"></param> - /// <returns></returns> - public static bool IsIANAReserved(IPAddress ipaddress) - { - - if (ipaddress == null) - { - throw new ArgumentNullException(nameof(ipaddress)); - } - - return IPNetwork.IANA_ABLK_RESERVED1.Contains(ipaddress) - || IPNetwork.IANA_BBLK_RESERVED1.Contains(ipaddress) - || IPNetwork.IANA_CBLK_RESERVED1.Contains(ipaddress); - } - - /// <summary> - /// return true if ipnetwork is contained in - /// IANA_ABLK_RESERVED1, IANA_BBLK_RESERVED1, IANA_CBLK_RESERVED1 - /// </summary> - /// <returns></returns> - public bool IsIANAReserved() - { - return IPNetwork.IANA_ABLK_RESERVED1.Contains(this) - || IPNetwork.IANA_BBLK_RESERVED1.Contains(this) - || IPNetwork.IANA_CBLK_RESERVED1.Contains(this); - } - - #endregion - - #region Subnet - - /// <summary> - /// Subnet a network into multiple nets of cidr mask - /// Subnet 192.168.0.0/24 into cidr 25 gives 192.168.0.0/25, 192.168.0.128/25 - /// Subnet 10.0.0.0/8 into cidr 9 gives 10.0.0.0/9, 10.128.0.0/9 - /// </summary> - /// <param name="cidr"></param> - /// <returns></returns> - public IPNetworkCollection Subnet(byte cidr) - { - IPNetworkCollection ipnetworkCollection = null; - IPNetwork.InternalSubnet(false, this, cidr, out ipnetworkCollection); - return ipnetworkCollection; - } - - /// <summary> - /// Subnet a network into multiple nets of cidr mask - /// Subnet 192.168.0.0/24 into cidr 25 gives 192.168.0.0/25, 192.168.0.128/25 - /// Subnet 10.0.0.0/8 into cidr 9 gives 10.0.0.0/9, 10.128.0.0/9 - /// </summary> - /// <param name="cidr"></param> - /// <returns></returns> - public bool TrySubnet(byte cidr, out IPNetworkCollection ipnetworkCollection) - { - IPNetworkCollection inc = null; - IPNetwork.InternalSubnet(true, this, cidr, out inc); - if (inc == null) - { - ipnetworkCollection = null; - return false; - } - - ipnetworkCollection = inc; - return true; - } - -#if TRAVISCI - public -#else - internal -#endif - static void InternalSubnet(bool trySubnet, IPNetwork network, byte cidr, out IPNetworkCollection ipnetworkCollection) - { - - if (network == null) - { - if (trySubnet == false) - { - throw new ArgumentNullException(nameof(network)); - } - ipnetworkCollection = null; - return; - } - - int maxCidr = network._family == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128; - if (cidr > maxCidr) - { - if (trySubnet == false) - { - throw new ArgumentOutOfRangeException(nameof(cidr)); - } - ipnetworkCollection = null; - return; - } - - if (cidr < network.Cidr) - { - if (trySubnet == false) - { - throw new ArgumentException("cidr"); - } - ipnetworkCollection = null; - return; - } - - ipnetworkCollection = new IPNetworkCollection(network, cidr); - return; - } - - - - #endregion - - #region Supernet - - /// <summary> - /// Supernet two consecutive cidr equal subnet into a single one - /// 192.168.0.0/24 + 192.168.1.0/24 = 192.168.0.0/23 - /// 10.1.0.0/16 + 10.0.0.0/16 = 10.0.0.0/15 - /// 192.168.0.0/24 + 192.168.0.0/25 = 192.168.0.0/24 - /// </summary> - /// <param name="network2"></param> - /// <returns></returns> - public IPNetwork Supernet(IPNetwork network2) - { - IPNetwork supernet = null; - IPNetwork.InternalSupernet(false, this, network2, out supernet); - return supernet; - } - - /// <summary> - /// Try to supernet two consecutive cidr equal subnet into a single one - /// 192.168.0.0/24 + 192.168.1.0/24 = 192.168.0.0/23 - /// 10.1.0.0/16 + 10.0.0.0/16 = 10.0.0.0/15 - /// 192.168.0.0/24 + 192.168.0.0/25 = 192.168.0.0/24 - /// </summary> - /// <param name="network2"></param> - /// <returns></returns> - public bool TrySupernet(IPNetwork network2, out IPNetwork supernet) - { - - IPNetwork outSupernet = null; - IPNetwork.InternalSupernet(true, this, network2, out outSupernet); - bool parsed = (outSupernet != null); - supernet = outSupernet; - return parsed; - } - -#if TRAVISCI - public -#else - internal -#endif - static void InternalSupernet(bool trySupernet, IPNetwork network1, IPNetwork network2, out IPNetwork supernet) - { - - if (network1 == null) - { - if (trySupernet == false) - { - throw new ArgumentNullException(nameof(network1)); - } - supernet = null; - return; - } - - if (network2 == null) - { - if (trySupernet == false) - { - throw new ArgumentNullException(nameof(network2)); - } - supernet = null; - return; - } - - - if (network1.Contains(network2)) - { - supernet = new IPNetwork(network1._network, network1._family, network1.Cidr); - return; - } - - if (network2.Contains(network1)) - { - supernet = new IPNetwork(network2._network, network2._family, network2.Cidr); - return; - } - - if (network1._cidr != network2._cidr) - { - if (trySupernet == false) - { - throw new ArgumentException("cidr"); - } - supernet = null; - return; - } - - var first = (network1._network < network2._network) ? network1 : network2; - var last = (network1._network > network2._network) ? network1 : network2; - - /// Starting from here : - /// network1 and network2 have the same cidr, - /// network1 does not contain network2, - /// network2 does not contain network1, - /// first is the lower subnet - /// last is the higher subnet - - - if ((first._broadcast + 1) != last._network) - { - if (trySupernet == false) - { - throw new ArgumentOutOfRangeException(nameof(trySupernet), "TrySupernet was false while the first and last networks are not adjacent."); - } - supernet = null; - return; - } - - var uintSupernet = first._network; - byte cidrSupernet = (byte)(first._cidr - 1); - - var networkSupernet = new IPNetwork(uintSupernet, first._family, cidrSupernet); - if (networkSupernet._network != first._network) - { - if (trySupernet == false) - { - throw new ArgumentException("network"); - } - supernet = null; - return; - } - supernet = networkSupernet; - return; - } - - #endregion - - #region GetHashCode - - public override int GetHashCode() - { - return string.Format("{0}|{1}|{2}", - this._ipaddress.GetHashCode(), - this._network.GetHashCode(), - this._cidr.GetHashCode()).GetHashCode(); - } - - #endregion - - #region SupernetArray - - /// <summary> - /// Supernet a list of subnet - /// 192.168.0.0/24 + 192.168.1.0/24 = 192.168.0.0/23 - /// 192.168.0.0/24 + 192.168.1.0/24 + 192.168.2.0/24 + 192.168.3.0/24 = 192.168.0.0/22 - /// </summary> - /// <param name="ipnetworks">The IP networks</param> - /// <returns></returns> - public static IPNetwork[] Supernet(IPNetwork[] ipnetworks) - { - InternalSupernet(false, ipnetworks, out var supernet); - return supernet; - } - - /// <summary> - /// Supernet a list of subnet - /// 192.168.0.0/24 + 192.168.1.0/24 = 192.168.0.0/23 - /// 192.168.0.0/24 + 192.168.1.0/24 + 192.168.2.0/24 + 192.168.3.0/24 = 192.168.0.0/22 - /// </summary> - /// <param name="ipnetworks"></param> - /// <param name="supernet"></param> - /// <returns></returns> - public static bool TrySupernet(IPNetwork[] ipnetworks, out IPNetwork[] supernet) - { - bool supernetted = InternalSupernet(true, ipnetworks, out supernet); - return supernetted; - - } - -#if TRAVISCI - public -#else - internal -#endif - static bool InternalSupernet(bool trySupernet, IPNetwork[] ipnetworks, out IPNetwork[] supernet) - { - - if (ipnetworks == null) - { - if (trySupernet == false) - { - throw new ArgumentNullException(nameof(ipnetworks)); - } - supernet = null; - return false; - } - - if (ipnetworks.Length <= 0) - { - supernet = new IPNetwork[0]; - return true; - } - - var supernetted = new List<IPNetwork>(); - var ipns = IPNetwork.Array2List(ipnetworks); - var current = IPNetwork.List2Stack(ipns); - int previousCount = 0; - int currentCount = current.Count; - - while (previousCount != currentCount) - { - - supernetted.Clear(); - while (current.Count > 1) - { - var ipn1 = current.Pop(); - var ipn2 = current.Peek(); - - IPNetwork outNetwork = null; - bool success = ipn1.TrySupernet(ipn2, out outNetwork); - if (success) - { - current.Pop(); - current.Push(outNetwork); - } - else - { - supernetted.Add(ipn1); - } - } - if (current.Count == 1) - { - supernetted.Add(current.Pop()); - } - - previousCount = currentCount; - currentCount = supernetted.Count; - current = IPNetwork.List2Stack(supernetted); - - } - supernet = supernetted.ToArray(); - return true; - } - - private static Stack<IPNetwork> List2Stack(List<IPNetwork> list) - { - var stack = new Stack<IPNetwork>(); - list.ForEach(new Action<IPNetwork>( - delegate (IPNetwork ipn) - { - stack.Push(ipn); - } - )); - return stack; - } - - private static List<IPNetwork> Array2List(IPNetwork[] array) - { - var ipns = new List<IPNetwork>(); - ipns.AddRange(array); - IPNetwork.RemoveNull(ipns); - ipns.Sort(new Comparison<IPNetwork>( - delegate (IPNetwork ipn1, IPNetwork ipn2) - { - int networkCompare = ipn1._network.CompareTo(ipn2._network); - if (networkCompare == 0) - { - int cidrCompare = ipn1._cidr.CompareTo(ipn2._cidr); - return cidrCompare; - } - return networkCompare; - } - )); - ipns.Reverse(); - - return ipns; - } - - private static void RemoveNull(List<IPNetwork> ipns) - { - ipns.RemoveAll(new Predicate<IPNetwork>( - delegate (IPNetwork ipn) - { - if (ipn == null) - { - return true; - } - return false; - } - )); - - } - - #endregion - - #region WideSubnet - - public static IPNetwork WideSubnet(string start, string end) - { - - if (string.IsNullOrEmpty(start)) - { - throw new ArgumentNullException(nameof(start)); - } - - if (string.IsNullOrEmpty(end)) - { - throw new ArgumentNullException(nameof(end)); - } - - if (!IPAddress.TryParse(start, out var startIP)) - { - throw new ArgumentException("start"); - } - - if (!IPAddress.TryParse(end, out var endIP)) - { - throw new ArgumentException("end"); - } - - if (startIP.AddressFamily != endIP.AddressFamily) - { - throw new NotSupportedException("MixedAddressFamily"); - } - - var ipnetwork = new IPNetwork(0, startIP.AddressFamily, 0); - for (byte cidr = 32; cidr >= 0; cidr--) - { - var wideSubnet = IPNetwork.Parse(start, cidr); - if (wideSubnet.Contains(endIP)) - { - ipnetwork = wideSubnet; - break; - } - } - return ipnetwork; - - } - - public static bool TryWideSubnet(IPNetwork[] ipnetworks, out IPNetwork ipnetwork) - { - IPNetwork ipn = null; - IPNetwork.InternalWideSubnet(true, ipnetworks, out ipn); - if (ipn == null) - { - ipnetwork = null; - return false; - } - ipnetwork = ipn; - return true; - } - - public static IPNetwork WideSubnet(IPNetwork[] ipnetworks) - { - IPNetwork ipn = null; - IPNetwork.InternalWideSubnet(false, ipnetworks, out ipn); - return ipn; - } - - internal static void InternalWideSubnet(bool tryWide, IPNetwork[] ipnetworks, out IPNetwork ipnetwork) - { - - if (ipnetworks == null) - { - if (tryWide == false) - { - throw new ArgumentNullException(nameof(ipnetworks)); - } - ipnetwork = null; - return; - } - - - IPNetwork[] nnin = Array.FindAll(ipnetworks, new Predicate<IPNetwork>( - delegate (IPNetwork ipnet) - { - return ipnet != null; - } - )); - - if (nnin.Length <= 0) - { - if (tryWide == false) - { - throw new ArgumentException("ipnetworks"); - } - ipnetwork = null; - return; - } - - if (nnin.Length == 1) - { - var ipn0 = nnin[0]; - ipnetwork = ipn0; - return; - } - - Array.Sort(nnin); - var nnin0 = nnin[0]; - var uintNnin0 = nnin0._ipaddress; - - var nninX = nnin[nnin.Length - 1]; - var ipaddressX = nninX.Broadcast; - - var family = ipnetworks[0]._family; - foreach (var ipnx in ipnetworks) - { - if (ipnx._family != family) - { - throw new ArgumentException("MixedAddressFamily"); - } - } - - var ipn = new IPNetwork(0, family, 0); - for (byte cidr = nnin0._cidr; cidr >= 0; cidr--) - { - var wideSubnet = new IPNetwork(uintNnin0, family, cidr); - if (wideSubnet.Contains(ipaddressX)) - { - ipn = wideSubnet; - break; - } - } - - ipnetwork = ipn; - return; - } - - #endregion - - #region Print - - /// <summary> - /// Print an ipnetwork in a clear representation string - /// </summary> - /// <returns></returns> - public string Print() - { - - var sw = new StringWriter(); - - sw.WriteLine("IPNetwork : {0}", ToString()); - sw.WriteLine("Network : {0}", Network); - sw.WriteLine("Netmask : {0}", Netmask); - sw.WriteLine("Cidr : {0}", Cidr); - sw.WriteLine("Broadcast : {0}", Broadcast); - sw.WriteLine("FirstUsable : {0}", FirstUsable); - sw.WriteLine("LastUsable : {0}", LastUsable); - sw.WriteLine("Usable : {0}", Usable); - - return sw.ToString(); - } - - #endregion - - #region TryGuessCidr - - /// <summary> - /// - /// Class Leading bits Default netmask - /// A (CIDR /8) 00 255.0.0.0 - /// A (CIDR /8) 01 255.0.0.0 - /// B (CIDR /16) 10 255.255.0.0 - /// C (CIDR /24) 11 255.255.255.0 - /// - /// </summary> - /// <param name="ip"></param> - /// <param name="cidr"></param> - /// <returns></returns> - public static bool TryGuessCidr(string ip, out byte cidr) - { - - IPAddress ipaddress = null; - bool parsed = IPAddress.TryParse(string.Format("{0}", ip), out ipaddress); - if (parsed == false) - { - cidr = 0; - return false; - } - - if (ipaddress.AddressFamily == AddressFamily.InterNetworkV6) - { - cidr = 64; - return true; - } - var uintIPAddress = IPNetwork.ToBigInteger(ipaddress); - uintIPAddress = uintIPAddress >> 29; - if (uintIPAddress <= 3) - { - cidr = 8; - return true; - } - else if (uintIPAddress <= 5) - { - cidr = 16; - return true; - } - else if (uintIPAddress <= 6) - { - cidr = 24; - return true; - } - - cidr = 0; - return false; - - } - - /// <summary> - /// Try to parse cidr. Have to be >= 0 and <= 32 or 128 - /// </summary> - /// <param name="sidr"></param> - /// <param name="cidr"></param> - /// <returns></returns> - public static bool TryParseCidr(string sidr, AddressFamily family, out byte? cidr) - { - - byte b = 0; - if (!byte.TryParse(sidr, out b)) - { - cidr = null; - return false; - } - - IPAddress netmask = null; - if (!IPNetwork.TryToNetmask(b, family, out netmask)) - { - cidr = null; - return false; - } - - cidr = b; - return true; - } - - #endregion - - #region ListIPAddress - - public IPAddressCollection ListIPAddress() - { - return new IPAddressCollection(this); - } - - #endregion - - /** - * Need a better way to do it - * -#region TrySubstractNetwork - - public static bool TrySubstractNetwork(IPNetwork[] ipnetworks, IPNetwork substract, out IEnumerable<IPNetwork> result) { - - if (ipnetworks == null) { - result = null; - return false; - } - if (ipnetworks.Length <= 0) { - result = null; - return false; - } - if (substract == null) { - result = null; - return false; - } - var results = new List<IPNetwork>(); - foreach (var ipn in ipnetworks) { - if (!Overlap(ipn, substract)) { - results.Add(ipn); - continue; - } - - var collection = ipn.Subnet(substract.Cidr); - var rtemp = new List<IPNetwork>(); - foreach(var subnet in collection) { - if (subnet != substract) { - rtemp.Add(subnet); - } - } - var supernets = Supernet(rtemp.ToArray()); - results.AddRange(supernets); - } - result = results; - return true; - } -#endregion - * **/ - - #region IComparable<IPNetwork> Members - - public static int Compare(IPNetwork left, IPNetwork right) - { - // two null IPNetworks are equal - if (ReferenceEquals(left, null) && ReferenceEquals(right, null)) return 0; - - // two same IPNetworks are equal - if (ReferenceEquals(left, right)) return 0; - - // null is always sorted first - if (ReferenceEquals(left, null)) return -1; - if (ReferenceEquals(right, null)) return 1; - - // first test the network - var result = left._network.CompareTo(right._network); - if (result != 0) return result; - - // then test the cidr - result = left._cidr.CompareTo(right._cidr); - return result; - } - - public int CompareTo(IPNetwork other) - { - return Compare(this, other); - } - - public int CompareTo(object obj) - { - // null is at less - if (obj == null) return 1; - - // convert to a proper Cidr object - var other = obj as IPNetwork; - - // type problem if null - if (other == null) - { - throw new ArgumentException( - "The supplied parameter is an invalid type. Please supply an IPNetwork type.", - nameof(obj)); - } - - // perform the comparision - return CompareTo(other); - } - - #endregion - - #region IEquatable<IPNetwork> Members - - public static bool Equals(IPNetwork left, IPNetwork right) - { - return Compare(left, right) == 0; - } - - public bool Equals(IPNetwork other) - { - return Equals(this, other); - } - - public override bool Equals(object obj) - { - return Equals(this, obj as IPNetwork); - } - - #endregion - - #region Operators - - public static bool operator ==(IPNetwork left, IPNetwork right) - { - return Equals(left, right); - } - - public static bool operator !=(IPNetwork left, IPNetwork right) - { - return !Equals(left, right); - } - - public static bool operator <(IPNetwork left, IPNetwork right) - { - return Compare(left, right) < 0; - } - - public static bool operator >(IPNetwork left, IPNetwork right) - { - return Compare(left, right) > 0; - } - - #endregion - - } -} diff --git a/Emby.Server.Implementations/Networking/IPNetwork/IPNetworkCollection.cs b/Emby.Server.Implementations/Networking/IPNetwork/IPNetworkCollection.cs deleted file mode 100644 index 4cda421e5..000000000 --- a/Emby.Server.Implementations/Networking/IPNetwork/IPNetworkCollection.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Numerics; - -namespace Emby.Server.Implementations.Networking.IPNetwork -{ - public class IPNetworkCollection : IEnumerable<IPNetwork>, IEnumerator<IPNetwork> - { - - private BigInteger _enumerator; - private byte _cidrSubnet; - private IPNetwork _ipnetwork; - - private byte _cidr => this._ipnetwork.Cidr; - - private BigInteger _broadcast => IPNetwork.ToBigInteger(this._ipnetwork.Broadcast); - - private BigInteger _lastUsable => IPNetwork.ToBigInteger(this._ipnetwork.LastUsable); - private BigInteger _network => IPNetwork.ToBigInteger(this._ipnetwork.Network); -#if TRAVISCI - public -#else - internal -#endif - IPNetworkCollection(IPNetwork ipnetwork, byte cidrSubnet) - { - - int maxCidr = ipnetwork.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128; - if (cidrSubnet > maxCidr) - { - throw new ArgumentOutOfRangeException(nameof(cidrSubnet)); - } - - if (cidrSubnet < ipnetwork.Cidr) - { - throw new ArgumentException("cidr"); - } - - this._cidrSubnet = cidrSubnet; - this._ipnetwork = ipnetwork; - this._enumerator = -1; - } - - #region Count, Array, Enumerator - - public BigInteger Count - { - get - { - var count = BigInteger.Pow(2, this._cidrSubnet - this._cidr); - return count; - } - } - - public IPNetwork this[BigInteger i] - { - get - { - if (i >= this.Count) - { - throw new ArgumentOutOfRangeException(nameof(i)); - } - - var last = this._ipnetwork.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 - ? this._lastUsable : this._broadcast; - var increment = (last - this._network) / this.Count; - var uintNetwork = this._network + ((increment + 1) * i); - var ipn = new IPNetwork(uintNetwork, this._ipnetwork.AddressFamily, this._cidrSubnet); - return ipn; - } - } - - #endregion - - #region IEnumerable Members - - IEnumerator<IPNetwork> IEnumerable<IPNetwork>.GetEnumerator() - { - return this; - } - - IEnumerator IEnumerable.GetEnumerator() - { - return this; - } - - #region IEnumerator<IPNetwork> Members - - public IPNetwork Current => this[this._enumerator]; - - #endregion - - #region IDisposable Members - - public void Dispose() - { - // nothing to dispose - return; - } - - #endregion - - #region IEnumerator Members - - object IEnumerator.Current => this.Current; - - public bool MoveNext() - { - this._enumerator++; - if (this._enumerator >= this.Count) - { - return false; - } - return true; - - } - - public void Reset() - { - this._enumerator = -1; - } - - #endregion - - #endregion - - } -} diff --git a/Emby.Server.Implementations/Networking/IPNetwork/LICENSE.txt b/Emby.Server.Implementations/Networking/IPNetwork/LICENSE.txt deleted file mode 100644 index 45d7392ac..000000000 --- a/Emby.Server.Implementations/Networking/IPNetwork/LICENSE.txt +++ /dev/null @@ -1,24 +0,0 @@ -Copyright (c) 2015, lduchosal -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - diff --git a/Emby.Server.Implementations/Networking/NetworkManager.cs b/Emby.Server.Implementations/Networking/NetworkManager.cs deleted file mode 100644 index 60cc9b88e..000000000 --- a/Emby.Server.Implementations/Networking/NetworkManager.cs +++ /dev/null @@ -1,727 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.Extensions; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.System; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Networking -{ - public class NetworkManager : INetworkManager - { - protected ILogger Logger { get; private set; } - - public event EventHandler NetworkChanged; - public Func<string[]> LocalSubnetsFn { get; set; } - - public NetworkManager( - ILoggerFactory loggerFactory, - IEnvironmentInfo environment) - { - Logger = loggerFactory.CreateLogger(nameof(NetworkManager)); - - // In FreeBSD these events cause a crash - if (environment.OperatingSystem != MediaBrowser.Model.System.OperatingSystem.BSD) - { - try - { - NetworkChange.NetworkAddressChanged += NetworkChange_NetworkAddressChanged; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error binding to NetworkAddressChanged event"); - } - - try - { - NetworkChange.NetworkAvailabilityChanged += NetworkChange_NetworkAvailabilityChanged; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error binding to NetworkChange_NetworkAvailabilityChanged event"); - } - } - } - - private void NetworkChange_NetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e) - { - Logger.LogDebug("NetworkAvailabilityChanged"); - OnNetworkChanged(); - } - - private void NetworkChange_NetworkAddressChanged(object sender, EventArgs e) - { - Logger.LogDebug("NetworkAddressChanged"); - OnNetworkChanged(); - } - - private void OnNetworkChanged() - { - lock (_localIpAddressSyncLock) - { - _localIpAddresses = null; - _macAddresses = null; - } - if (NetworkChanged != null) - { - NetworkChanged(this, EventArgs.Empty); - } - } - - private IpAddressInfo[] _localIpAddresses; - private readonly object _localIpAddressSyncLock = new object(); - - public IpAddressInfo[] GetLocalIpAddresses() - { - lock (_localIpAddressSyncLock) - { - if (_localIpAddresses == null) - { - var addresses = GetLocalIpAddressesInternal().Result.Select(ToIpAddressInfo).ToArray(); - - _localIpAddresses = addresses; - - return addresses; - } - return _localIpAddresses; - } - } - - private async Task<List<IPAddress>> GetLocalIpAddressesInternal() - { - var list = GetIPsDefault() - .ToList(); - - if (list.Count == 0) - { - list.AddRange(await GetLocalIpAddressesFallback().ConfigureAwait(false)); - } - - var listClone = list.ToList(); - - return list - .OrderBy(i => i.AddressFamily == AddressFamily.InterNetwork ? 0 : 1) - .ThenBy(i => listClone.IndexOf(i)) - .Where(FilterIpAddress) - .GroupBy(i => i.ToString()) - .Select(x => x.First()) - .ToList(); - } - - private static bool FilterIpAddress(IPAddress address) - { - var addressString = address.ToString(); - - if (addressString.StartsWith("169.", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return true; - } - - public bool IsInPrivateAddressSpace(string endpoint) - { - return IsInPrivateAddressSpace(endpoint, true); - } - - 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); - - if (string.Equals(endpoint, originalEndpoint, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - // Private address space: - // http://en.wikipedia.org/wiki/Private_network - - if (endpoint.StartsWith("172.", StringComparison.OrdinalIgnoreCase)) - { - return Is172AddressPrivate(endpoint); - } - - if (endpoint.StartsWith("localhost", StringComparison.OrdinalIgnoreCase) || - endpoint.StartsWith("127.", StringComparison.OrdinalIgnoreCase) || - endpoint.StartsWith("169.", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (checkSubnets && endpoint.StartsWith("192.168", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint)) - { - return true; - } - - return false; - } - - 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; - } - - private Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal); - private List<string> GetSubnets(string endpointFirstPart) - { - lock (_subnetLookup) - { - 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")) 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; - } - } - - private static bool Is172AddressPrivate(string endpoint) - { - for (var i = 16; i <= 31; i++) - { - if (endpoint.StartsWith("172." + i.ToString(CultureInfo.InvariantCulture) + ".", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - public bool IsInLocalNetwork(string endpoint) - { - return IsInLocalNetworkInternal(endpoint, true); - } - - public bool IsAddressInSubnets(string addressString, string[] subnets) - { - return IsAddressInSubnets(IPAddress.Parse(addressString), addressString, subnets); - } - - private static bool IsAddressInSubnets(IPAddress address, string addressString, string[] subnets) - { - foreach (var subnet in subnets) - { - var normalizedSubnet = subnet.Trim(); - - if (string.Equals(normalizedSubnet, addressString, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (normalizedSubnet.IndexOf('/') != -1) - { - var ipnetwork = IPNetwork.IPNetwork.Parse(normalizedSubnet); - if (ipnetwork.Contains(address)) - { - return true; - } - } - } - - 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).Result.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 List<IPAddress> GetIPsDefault() - { - 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 new List<IPAddress>(); - } - - return interfaces.SelectMany(network => - { - - try - { - // suppress logging because it might be causing nas device wake up - //logger.LogDebug("Querying interface: {0}. Type: {1}. Status: {2}", network.Name, network.NetworkInterfaceType, network.OperationalStatus); - - var ipProperties = network.GetIPProperties(); - - // Try to exclude virtual adapters - // http://stackoverflow.com/questions/8089685/c-sharp-finding-my-machines-local-ip-address-and-not-the-vms - var addr = ipProperties.GatewayAddresses.FirstOrDefault(); - if (addr == null || string.Equals(addr.Address.ToString(), "0.0.0.0", StringComparison.OrdinalIgnoreCase)) - { - return new List<IPAddress>(); - } - - return ipProperties.UnicastAddresses - .Select(i => i.Address) - .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6) - .ToList(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error querying network interface"); - return new List<IPAddress>(); - } - - }).GroupBy(i => i.ToString()) - .Select(x => x.First()) - .ToList(); - } - - 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; - } - - public int GetRandomUnusedUdpPort() - { - var localEndPoint = new IPEndPoint(IPAddress.Any, 0); - using (var udpClient = new UdpClient(localEndPoint)) - { - var port = ((IPEndPoint)(udpClient.Client.LocalEndPoint)).Port; - return port; - } - } - - private List<string> _macAddresses; - public List<string> GetMacAddresses() - { - if (_macAddresses == null) - { - _macAddresses = GetMacAddressesInternal(); - } - return _macAddresses; - } - - private static List<string> GetMacAddressesInternal() - { - return NetworkInterface.GetAllNetworkInterfaces() - .Where(i => i.NetworkInterfaceType != NetworkInterfaceType.Loopback) - .Select(i => - { - try - { - var physicalAddress = i.GetPhysicalAddress(); - - if (physicalAddress == null) - { - return null; - } - - return physicalAddress.ToString(); - } - catch (Exception) - { - //TODO Log exception. - return null; - } - }) - .Where(i => i != null) - .ToList(); - } - - /// <summary> - /// Parses the specified endpointstring. - /// </summary> - /// <param name="endpointstring">The endpointstring.</param> - /// <returns>IPEndPoint.</returns> - public IPEndPoint Parse(string endpointstring) - { - return Parse(endpointstring, -1).Result; - } - - /// <summary> - /// Parses the specified endpointstring. - /// </summary> - /// <param name="endpointstring">The endpointstring.</param> - /// <param name="defaultport">The defaultport.</param> - /// <returns>IPEndPoint.</returns> - /// <exception cref="ArgumentException">Endpoint descriptor may not be empty.</exception> - /// <exception cref="FormatException"></exception> - private static async Task<IPEndPoint> Parse(string endpointstring, int defaultport) - { - if (string.IsNullOrEmpty(endpointstring) - || endpointstring.Trim().Length == 0) - { - throw new ArgumentException("Endpoint descriptor may not be empty."); - } - - if (defaultport != -1 && - (defaultport < IPEndPoint.MinPort - || defaultport > IPEndPoint.MaxPort)) - { - throw new ArgumentException(string.Format("Invalid default port '{0}'", defaultport)); - } - - string[] values = endpointstring.Split(new char[] { ':' }); - IPAddress ipaddy; - int port = -1; - - //check if we have an IPv6 or ports - if (values.Length <= 2) // ipv4 or hostname - { - port = values.Length == 1 ? defaultport : GetPort(values[1]); - - //try to use the address as IPv4, otherwise get hostname - if (!IPAddress.TryParse(values[0], out ipaddy)) - ipaddy = await GetIPfromHost(values[0]).ConfigureAwait(false); - } - else if (values.Length > 2) //ipv6 - { - //could [a:b:c]:d - if (values[0].StartsWith("[") && values[values.Length - 2].EndsWith("]")) - { - string ipaddressstring = string.Join(":", values.Take(values.Length - 1).ToArray()); - ipaddy = IPAddress.Parse(ipaddressstring); - port = GetPort(values[values.Length - 1]); - } - else //[a:b:c] or a:b:c - { - ipaddy = IPAddress.Parse(endpointstring); - port = defaultport; - } - } - else - { - throw new FormatException(string.Format("Invalid endpoint ipaddress '{0}'", endpointstring)); - } - - if (port == -1) - throw new ArgumentException(string.Format("No port specified: '{0}'", endpointstring)); - - return new IPEndPoint(ipaddy, port); - } - - protected static readonly CultureInfo UsCulture = new CultureInfo("en-US"); - - /// <summary> - /// Gets the port. - /// </summary> - /// <param name="p">The p.</param> - /// <returns>System.Int32.</returns> - /// <exception cref="FormatException"></exception> - private static int GetPort(string p) - { - if (!int.TryParse(p, out var port) - || port < IPEndPoint.MinPort - || port > IPEndPoint.MaxPort) - { - throw new FormatException(string.Format("Invalid end point port '{0}'", p)); - } - - return port; - } - - /// <summary> - /// Gets the I pfrom host. - /// </summary> - /// <param name="p">The p.</param> - /// <returns>IPAddress.</returns> - /// <exception cref="ArgumentException"></exception> - private static async Task<IPAddress> GetIPfromHost(string p) - { - var hosts = await Dns.GetHostAddressesAsync(p).ConfigureAwait(false); - - if (hosts == null || hosts.Length == 0) - throw new ArgumentException(string.Format("Host not found: {0}", p)); - - return hosts[0]; - } - - public IpAddressInfo ParseIpAddress(string ipAddress) - { - if (TryParseIpAddress(ipAddress, out var info)) - { - return info; - } - - throw new ArgumentException("Invalid ip address: " + ipAddress); - } - - public bool TryParseIpAddress(string ipAddress, out IpAddressInfo ipAddressInfo) - { - if (IPAddress.TryParse(ipAddress, out var address)) - { - ipAddressInfo = ToIpAddressInfo(address); - return true; - } - - ipAddressInfo = null; - return false; - } - - public static IpEndPointInfo ToIpEndPointInfo(IPEndPoint endpoint) - { - if (endpoint == null) - { - return null; - } - - return new IpEndPointInfo(ToIpAddressInfo(endpoint.Address), endpoint.Port); - } - - public static IPEndPoint ToIPEndPoint(IpEndPointInfo endpoint) - { - if (endpoint == null) - { - return null; - } - - return new IPEndPoint(ToIPAddress(endpoint.IpAddress), endpoint.Port); - } - - public static IPAddress ToIPAddress(IpAddressInfo address) - { - if (address.Equals(IpAddressInfo.Any)) - { - return IPAddress.Any; - } - if (address.Equals(IpAddressInfo.IPv6Any)) - { - return IPAddress.IPv6Any; - } - if (address.Equals(IpAddressInfo.Loopback)) - { - return IPAddress.Loopback; - } - if (address.Equals(IpAddressInfo.IPv6Loopback)) - { - return IPAddress.IPv6Loopback; - } - - return IPAddress.Parse(address.Address); - } - - public static IpAddressInfo ToIpAddressInfo(IPAddress address) - { - if (address.Equals(IPAddress.Any)) - { - return IpAddressInfo.Any; - } - if (address.Equals(IPAddress.IPv6Any)) - { - return IpAddressInfo.IPv6Any; - } - if (address.Equals(IPAddress.Loopback)) - { - return IpAddressInfo.Loopback; - } - if (address.Equals(IPAddress.IPv6Loopback)) - { - return IpAddressInfo.IPv6Loopback; - } - return new IpAddressInfo(address.ToString(), address.AddressFamily == AddressFamily.InterNetworkV6 ? IpAddressFamily.InterNetworkV6 : IpAddressFamily.InterNetwork); - } - - public async Task<IpAddressInfo[]> GetHostAddressesAsync(string host) - { - var addresses = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false); - return addresses.Select(ToIpAddressInfo).ToArray(); - } - - /// <summary> - /// Gets the network shares. - /// </summary> - /// <param name="path">The path.</param> - /// <returns>IEnumerable{NetworkShare}.</returns> - public virtual IEnumerable<NetworkShare> GetNetworkShares(string path) - { - return new List<NetworkShare>(); - } - - /// <summary> - /// Gets available devices within the domain - /// </summary> - /// <returns>PC's in the Domain</returns> - public virtual IEnumerable<FileSystemEntryInfo> GetNetworkDevices() - { - return new List<FileSystemEntryInfo>(); - } - } -} diff --git a/Emby.Server.Implementations/Playlists/PlaylistImageProvider.cs b/Emby.Server.Implementations/Playlists/PlaylistImageProvider.cs deleted file mode 100644 index 8a7c1492d..000000000 --- a/Emby.Server.Implementations/Playlists/PlaylistImageProvider.cs +++ /dev/null @@ -1,126 +0,0 @@ -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.Library; -using MediaBrowser.Controller.Playlists; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; - -namespace Emby.Server.Implementations.Playlists -{ - public class PlaylistImageProvider : BaseDynamicImageProvider<Playlist> - { - public PlaylistImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor) : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - } - - protected override List<BaseItem> GetItemsWithImages(BaseItem item) - { - var playlist = (Playlist)item; - - return playlist.GetManageableItems() - .Select(i => - { - var subItem = i.Item2; - - var episode = subItem as Episode; - - if (episode != null) - { - var series = episode.Series; - if (series != null && series.HasImage(ImageType.Primary)) - { - return series; - } - } - - if (subItem.HasImage(ImageType.Primary)) - { - return subItem; - } - - var parent = subItem.GetOwner() ?? subItem.GetParent(); - - if (parent != null && parent.HasImage(ImageType.Primary)) - { - if (parent is MusicAlbum) - { - return parent; - } - } - - return null; - }) - .Where(i => i != null) - .OrderBy(i => Guid.NewGuid()) - .GroupBy(x => x.Id) - .Select(x => x.First()) - .ToList(); - } - } - - public class MusicGenreImageProvider : BaseDynamicImageProvider<MusicGenre> - { - private readonly ILibraryManager _libraryManager; - - public MusicGenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - protected override List<BaseItem> GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(MusicAlbum).Name, typeof(MusicVideo).Name, typeof(Audio).Name }, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.Random, SortOrder.Ascending) }, - Limit = 4, - Recursive = true, - ImageTypes = new[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false) - - }); - } - } - - public class GenreImageProvider : BaseDynamicImageProvider<Genre> - { - private readonly ILibraryManager _libraryManager; - - public GenreImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - protected override List<BaseItem> GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Genres = new[] { item.Name }, - IncludeItemTypes = new[] { typeof(Series).Name, typeof(Movie).Name }, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.Random, SortOrder.Ascending) }, - Limit = 4, - Recursive = true, - ImageTypes = new[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false) - }); - } - - //protected override Task<string> CreateImage(IHasMetadata item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) - //{ - // return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); - //} - } - -} diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 29836e0bf..b07798fa4 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -1,21 +1,31 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Playlists; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using PlaylistsNET.Content; using PlaylistsNET.Models; +using Genre = MediaBrowser.Controller.Entities.Genre; +using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; namespace Emby.Server.Implementations.Playlists { @@ -24,24 +34,27 @@ namespace Emby.Server.Implementations.Playlists private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _iLibraryMonitor; - private readonly ILogger _logger; + private readonly ILogger<PlaylistManager> _logger; private readonly IUserManager _userManager; private readonly IProviderManager _providerManager; + private readonly IConfiguration _appConfig; public PlaylistManager( ILibraryManager libraryManager, IFileSystem fileSystem, ILibraryMonitor iLibraryMonitor, - ILoggerFactory loggerFactory, + ILogger<PlaylistManager> logger, IUserManager userManager, - IProviderManager providerManager) + IProviderManager providerManager, + IConfiguration appConfig) { _libraryManager = libraryManager; _fileSystem = fileSystem; _iLibraryMonitor = iLibraryMonitor; - _logger = loggerFactory.CreateLogger(nameof(PlaylistManager)); + _logger = logger; _userManager = userManager; _providerManager = providerManager; + _appConfig = appConfig; } public IEnumerable<Playlist> GetPlaylists(Guid userId) @@ -55,10 +68,8 @@ namespace Emby.Server.Implementations.Playlists { var name = options.Name; - var folderName = _fileSystem.GetValidFilename(name) + " [playlist]"; - + var folderName = _fileSystem.GetValidFilename(name); var parentFolder = GetPlaylistsFolder(Guid.Empty); - if (parentFolder == null) { throw new ArgumentException(); @@ -89,8 +100,7 @@ namespace Emby.Server.Implementations.Playlists } else { - var folder = item as Folder; - if (folder != null) + if (item is Folder folder) { options.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist) .Select(i => i.MediaType) @@ -129,7 +139,7 @@ namespace Emby.Server.Implementations.Playlists { new Share { - UserId = options.UserId.Equals(Guid.Empty) ? null : options.UserId.ToString("N"), + UserId = options.UserId.Equals(Guid.Empty) ? null : options.UserId.ToString("N", CultureInfo.InvariantCulture), CanEdit = true } } @@ -137,23 +147,20 @@ namespace Emby.Server.Implementations.Playlists playlist.SetMediaType(options.MediaType); - parentFolder.AddChild(playlist, CancellationToken.None); + parentFolder.AddChild(playlist); - await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) { ForceSave = true }, CancellationToken.None) + await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) .ConfigureAwait(false); - if (options.ItemIdList.Length > 0) + if (options.ItemIdList.Count > 0) { - AddToPlaylistInternal(playlist.Id.ToString("N"), options.ItemIdList, user, new DtoOptions(false) + await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false) { EnableImages = true - }); + }).ConfigureAwait(false); } - return new PlaylistCreationResult - { - Id = playlist.Id.ToString("N") - }; + return new PlaylistCreationResult(playlist.Id.ToString("N", CultureInfo.InvariantCulture)); } finally { @@ -179,59 +186,81 @@ namespace Emby.Server.Implementations.Playlists return Playlist.GetPlaylistItems(playlistMediaType, items, user, options); } - public void AddToPlaylist(string playlistId, IEnumerable<Guid> itemIds, Guid userId) + public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId) { var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId); - AddToPlaylistInternal(playlistId, itemIds, user, new DtoOptions(false) + return AddToPlaylistInternal(playlistId, itemIds, user, new DtoOptions(false) { EnableImages = true }); } - private void AddToPlaylistInternal(string playlistId, IEnumerable<Guid> itemIds, User user, DtoOptions options) + private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options) { - var playlist = _libraryManager.GetItemById(playlistId) as Playlist; + // Retrieve the existing playlist + var playlist = _libraryManager.GetItemById(playlistId) as Playlist + ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); - if (playlist == null) + // Retrieve all the items to be added to the playlist + var newItems = GetPlaylistItems(newItemIds, playlist.MediaType, user, options) + .Where(i => i.SupportsAddingToPlaylist); + + // Filter out duplicate items, if necessary + if (!_appConfig.DoPlaylistsAllowDuplicates()) { - throw new ArgumentException("No Playlist exists with the supplied Id"); + var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); + newItems = newItems + .Where(i => !existingIds.Contains(i.Id)) + .Distinct(); } - var list = new List<LinkedChild>(); - - var items = (GetPlaylistItems(itemIds, playlist.MediaType, user, options)) - .Where(i => i.SupportsAddingToPlaylist) + // Create a list of the new linked children to add to the playlist + var childrenToAdd = newItems + .Select(LinkedChild.Create) .ToList(); - foreach (var item in items) + // Log duplicates that have been ignored, if any + int numDuplicates = newItemIds.Count - childrenToAdd.Count; + if (numDuplicates > 0) { - list.Add(LinkedChild.Create(item)); + _logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name); } - var newList = playlist.LinkedChildren.ToList(); - newList.AddRange(list); - playlist.LinkedChildren = newList.ToArray(); + // Do nothing else if there are no items to add to the playlist + if (childrenToAdd.Count == 0) + { + return; + } + + // Create a new array with the updated playlist items + var newLinkedChildren = new LinkedChild[playlist.LinkedChildren.Length + childrenToAdd.Count]; + playlist.LinkedChildren.CopyTo(newLinkedChildren, 0); + childrenToAdd.CopyTo(newLinkedChildren, playlist.LinkedChildren.Length); - playlist.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + // Update the playlist in the repository + playlist.LinkedChildren = newLinkedChildren; + await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + // Update the playlist on disk if (playlist.IsFile) { SavePlaylistFile(playlist); } - _providerManager.QueueRefresh(playlist.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - ForceSave = true - - }, RefreshPriority.High); + // Refresh playlist metadata + _providerManager.QueueRefresh( + playlist.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + RefreshPriority.High); } - public void RemoveFromPlaylist(string playlistId, IEnumerable<string> entryIds) + public async Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds) { - var playlist = _libraryManager.GetItemById(playlistId) as Playlist; - - if (playlist == null) + if (_libraryManager.GetItemById(playlistId) is not Playlist playlist) { throw new ArgumentException("No Playlist exists with the supplied Id"); } @@ -246,25 +275,25 @@ namespace Emby.Server.Implementations.Playlists .Select(i => i.Item1) .ToArray(); - playlist.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); if (playlist.IsFile) { SavePlaylistFile(playlist); } - _providerManager.QueueRefresh(playlist.Id, new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) - { - ForceSave = true - - }, RefreshPriority.High); + _providerManager.QueueRefresh( + playlist.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + RefreshPriority.High); } - public void MoveItem(string playlistId, string entryId, int newIndex) + public async Task MoveItemAsync(string playlistId, string entryId, int newIndex) { - var playlist = _libraryManager.GetItemById(playlistId) as Playlist; - - if (playlist == null) + if (_libraryManager.GetItemById(playlistId) is not Playlist playlist) { throw new ArgumentException("No Playlist exists with the supplied Id"); } @@ -295,7 +324,7 @@ namespace Emby.Server.Implementations.Playlists playlist.LinkedChildren = newList.ToArray(); - playlist.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); if (playlist.IsFile) { @@ -305,7 +334,8 @@ namespace Emby.Server.Implementations.Playlists private void SavePlaylistFile(Playlist item) { - // This is probably best done as a metatata provider, but saving a file over itself will first require some core work to prevent this from happening when not needed + // this is probably best done as a metadata provider + // saving a file over itself will require some work to prevent this from happening when not needed var playlistPath = item.Path; var extension = Path.GetExtension(playlistPath); @@ -321,28 +351,28 @@ namespace Emby.Server.Implementations.Playlists AlbumTitle = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } - var hasArtist = child as IHasArtist; - if (hasArtist != null) + if (child is IHasArtist hasArtist) { - entry.TrackArtist = hasArtist.Artists.FirstOrDefault(); + entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null; } if (child.RunTimeTicks.HasValue) { entry.Duration = TimeSpan.FromTicks(child.RunTimeTicks.Value); } + playlist.PlaylistEntries.Add(entry); } string text = new WplContent().ToText(playlist); File.WriteAllText(playlistPath, text); } + if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase)) { var playlist = new ZplPlaylist(); @@ -355,32 +385,34 @@ namespace Emby.Server.Implementations.Playlists AlbumTitle = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } - var hasArtist = child as IHasArtist; - if (hasArtist != null) + if (child is IHasArtist hasArtist) { - entry.TrackArtist = hasArtist.Artists.FirstOrDefault(); + entry.TrackArtist = hasArtist.Artists.Count > 0 ? hasArtist.Artists[0] : null; } if (child.RunTimeTicks.HasValue) { entry.Duration = TimeSpan.FromTicks(child.RunTimeTicks.Value); } + playlist.PlaylistEntries.Add(entry); } string text = new ZplContent().ToText(playlist); File.WriteAllText(playlistPath, text); } + if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase)) { - var playlist = new M3uPlaylist(); - playlist.IsExtended = true; + var playlist = new M3uPlaylist + { + IsExtended = true + }; foreach (var child in item.GetLinkedChildren()) { var entry = new M3uPlaylistEntry() @@ -390,22 +422,23 @@ namespace Emby.Server.Implementations.Playlists Album = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } if (child.RunTimeTicks.HasValue) { entry.Duration = TimeSpan.FromTicks(child.RunTimeTicks.Value); } + playlist.PlaylistEntries.Add(entry); } string text = new M3uContent().ToText(playlist); File.WriteAllText(playlistPath, text); } + if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase)) { var playlist = new M3uPlaylist(); @@ -419,22 +452,23 @@ namespace Emby.Server.Implementations.Playlists Album = child.Album }; - var hasAlbumArtist = child as IHasAlbumArtist; - if (hasAlbumArtist != null) + if (child is IHasAlbumArtist hasAlbumArtist) { - entry.AlbumArtist = hasAlbumArtist.AlbumArtists.FirstOrDefault(); + entry.AlbumArtist = hasAlbumArtist.AlbumArtists.Count > 0 ? hasAlbumArtist.AlbumArtists[0] : null; } if (child.RunTimeTicks.HasValue) { entry.Duration = TimeSpan.FromTicks(child.RunTimeTicks.Value); } + playlist.PlaylistEntries.Add(entry); } - string text = new M3u8Content().ToText(playlist); + string text = new M3uContent().ToText(playlist); File.WriteAllText(playlistPath, text); } + if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase)) { var playlist = new PlsPlaylist(); @@ -450,6 +484,7 @@ namespace Emby.Server.Implementations.Playlists { entry.Length = TimeSpan.FromTicks(child.RunTimeTicks.Value); } + playlist.PlaylistEntries.Add(entry); } @@ -475,15 +510,19 @@ namespace Emby.Server.Implementations.Playlists throw new ArgumentException("File absolute path was null or empty.", nameof(fileAbsolutePath)); } - if (!folderPath.EndsWith(Path.DirectorySeparatorChar.ToString())) + if (!folderPath.EndsWith(Path.DirectorySeparatorChar)) { - folderPath = folderPath + Path.DirectorySeparatorChar; + folderPath += Path.DirectorySeparatorChar; } var folderUri = new Uri(folderPath); var fileAbsoluteUri = new Uri(fileAbsolutePath); - if (folderUri.Scheme != fileAbsoluteUri.Scheme) { return fileAbsolutePath; } // path can't be made relative. + // path can't be made relative + if (folderUri.Scheme != fileAbsoluteUri.Scheme) + { + return fileAbsolutePath; + } var relativeUri = folderUri.MakeRelativeUri(fileAbsoluteUri); string relativePath = Uri.UnescapeDataString(relativeUri.ToString()); @@ -496,24 +535,12 @@ namespace Emby.Server.Implementations.Playlists return relativePath; } - private static string UnEscape(string content) - { - if (content == null) return content; - return content.Replace("&", "&").Replace("'", "'").Replace(""", "\"").Replace(">", ">").Replace("<", "<"); - } - - private static string Escape(string content) - { - if (content == null) return null; - return content.Replace("&", "&").Replace("'", "'").Replace("\"", """).Replace(">", ">").Replace("<", "<"); - } - public Folder GetPlaylistsFolder(Guid userId) { - var typeName = "PlaylistsFolder"; + const string TypeName = "PlaylistsFolder"; - return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal)) ?? - _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, typeName, StringComparison.Ordinal)); + return _libraryManager.RootFolder.Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)) ?? + _libraryManager.GetUserRootFolder().Children.OfType<Folder>().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)); } } } diff --git a/Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs index f4decc856..8b1cee89d 100644 --- a/Emby.Server.Implementations/Playlists/ManualPlaylistsFolder.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs @@ -1,9 +1,12 @@ +#pragma warning disable CS1591 + using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; namespace Emby.Server.Implementations.Playlists { @@ -14,6 +17,15 @@ namespace Emby.Server.Implementations.Playlists Name = "Playlists"; } + [JsonIgnore] + public override bool IsHidden => true; + + [JsonIgnore] + public override bool SupportsInheritedParentImages => false; + + [JsonIgnore] + public override string CollectionType => MediaBrowser.Model.Entities.CollectionType.Playlists; + public override bool IsVisible(User user) { return base.IsVisible(user) && GetChildren(user, true).Any(); @@ -24,15 +36,6 @@ namespace Emby.Server.Implementations.Playlists return base.GetEligibleChildrenForRecursiveChildren(user).OfType<Playlist>(); } - [IgnoreDataMember] - public override bool IsHidden => true; - - [IgnoreDataMember] - public override bool SupportsInheritedParentImages => false; - - [IgnoreDataMember] - public override string CollectionType => MediaBrowser.Model.Entities.CollectionType.Playlists; - protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query) { if (query.User == null) @@ -42,10 +45,14 @@ namespace Emby.Server.Implementations.Playlists } query.Recursive = true; - query.IncludeItemTypes = new string[] { "Playlist" }; + query.IncludeItemTypes = new[] { "Playlist" }; query.Parent = null; return LibraryManager.GetItemsResult(query); } + + public override string GetClientTypeName() + { + return "ManualPlaylistsFolder"; + } } } - diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs new file mode 100644 index 000000000..d52c0b2a1 --- /dev/null +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -0,0 +1,757 @@ +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 Jellyfin.Extensions.Json; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Common; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +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; + + /// <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>(); + } + + private IHttpClientFactory HttpClientFactory + { + get + { + return _httpClientFactory ??= _appHost.Resolve<IHttpClientFactory>(); + } + } + + /// <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 = AsyncFile.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 79ba9374c..41c396ac1 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 @@ -10,10 +11,12 @@ using System.Runtime.InteropServices; [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("Jellyfin Project")] [assembly: AssemblyProduct("Jellyfin Server")] -[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License Version 2")] +[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] +[assembly: InternalsVisibleTo("Emby.Server.Implementations.Fuzz")] // 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..c81c26994 --- /dev/null +++ b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.QuickConnect; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.QuickConnect; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.QuickConnect +{ + /// <summary> + /// Quick connect implementation. + /// </summary> + public class QuickConnectManager : IQuickConnect + { + /// <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 ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new (); + private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new (); + + private readonly IServerConfigurationManager _config; + private readonly ILogger<QuickConnectManager> _logger; + private readonly ISessionManager _sessionManager; + + /// <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="sessionManager">Session Manager.</param> + public QuickConnectManager( + IServerConfigurationManager config, + ILogger<QuickConnectManager> logger, + ISessionManager sessionManager) + { + _config = config; + _logger = logger; + _sessionManager = sessionManager; + } + + /// <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(AuthorizationInfo authorizationInfo) + { + if (string.IsNullOrEmpty(authorizationInfo.DeviceId)) + { + throw new ArgumentException(nameof(authorizationInfo.DeviceId) + " is required"); + } + + if (string.IsNullOrEmpty(authorizationInfo.Device)) + { + throw new ArgumentException(nameof(authorizationInfo.Device) + " is required"); + } + + if (string.IsNullOrEmpty(authorizationInfo.Client)) + { + throw new ArgumentException(nameof(authorizationInfo.Client) + " is required"); + } + + if (string.IsNullOrEmpty(authorizationInfo.Version)) + { + throw new ArgumentException(nameof(authorizationInfo.Version) + "is required"); + } + + AssertActive(); + ExpireRequests(); + + var secret = GenerateSecureRandom(); + var code = GenerateCode(); + var result = new QuickConnectResult( + secret, + code, + DateTime.UtcNow, + authorizationInfo.DeviceId, + authorizationInfo.Device, + authorizationInfo.Client, + authorizationInfo.Version); + + _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) + { + RandomNumberGenerator.Fill(raw); + scale = BitConverter.ToUInt32(raw); + } + + int code = (int)(min + ((max - min) * (scale / (double)uint.MaxValue))); + return code.ToString(CultureInfo.InvariantCulture); + } + + /// <inheritdoc/> + public async Task<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"); + } + + // 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.UtcNow.Add(TimeSpan.FromMinutes(1)); + + var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest + { + UserId = userId, + DeviceId = result.DeviceId, + DeviceName = result.DeviceName, + App = result.AppName, + AppVersion = result.AppVersion + }).ConfigureAwait(false); + + _authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult); + result.Authenticated = true; + _currentRequests[code] = result; + + _logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId); + + return true; + } + + /// <inheritdoc/> + public AuthenticationResult GetAuthorizedRequest(string secret) + { + AssertActive(); + ExpireRequests(); + + if (!_authorizedSecrets.TryGetValue(secret, out var result)) + { + throw new ResourceNotFoundException("Unable to find request"); + } + + return result.AuthenticationResult; + } + + private string GenerateSecureRandom(int length = 32) + { + Span<byte> bytes = stackalloc byte[length]; + RandomNumberGenerator.Fill(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); + } + } + } + + foreach (var (secret, (timestamp, _)) in _authorizedSecrets) + { + if (expireAll || timestamp < minTime) + { + _logger.LogDebug("Removing expired secret {Secret}", secret); + if (!_authorizedSecrets.TryRemove(secret, out _)) + { + _logger.LogWarning("Secret {Secret} already expired", secret); + } + } + } + } + } +} diff --git a/Emby.Server.Implementations/Reflection/AssemblyInfo.cs b/Emby.Server.Implementations/Reflection/AssemblyInfo.cs deleted file mode 100644 index 9d16fe43f..000000000 --- a/Emby.Server.Implementations/Reflection/AssemblyInfo.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using MediaBrowser.Model.Reflection; - -namespace Emby.Server.Implementations.Reflection -{ - public class AssemblyInfo : IAssemblyInfo - { - public Stream GetManifestResourceStream(Type type, string resource) - { - return type.Assembly.GetManifestResourceStream(resource); - } - - public string[] GetManifestResourceNames(Type type) - { - return type.Assembly.GetManifestResourceNames(); - } - - public Assembly[] GetCurrentAssemblies() - { - return AppDomain.CurrentDomain.GetAssemblies(); - } - } -} diff --git a/Emby.Server.Implementations/ResourceFileManager.cs b/Emby.Server.Implementations/ResourceFileManager.cs deleted file mode 100644 index 890d848f4..000000000 --- a/Emby.Server.Implementations/ResourceFileManager.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations -{ - public class ResourceFileManager : IResourceFileManager - { - private readonly IFileSystem _fileSystem; - private readonly ILogger _logger; - private readonly IHttpResultFactory _resultFactory; - - public ResourceFileManager( - IHttpResultFactory resultFactory, - ILoggerFactory loggerFactory, - IFileSystem fileSystem) - { - _resultFactory = resultFactory; - _logger = loggerFactory.CreateLogger("ResourceManager"); - _fileSystem = fileSystem; - } - - public Stream GetResourceFileStream(string basePath, string virtualPath) - { - return _fileSystem.GetFileStream(GetResourcePath(basePath, virtualPath), FileOpenMode.Open, FileAccessMode.Read, FileShareMode.ReadWrite, true); - } - - public Task<object> GetStaticFileResult(IRequest request, string basePath, string virtualPath, string contentType, TimeSpan? cacheDuration) - { - return _resultFactory.GetStaticFileResult(request, GetResourcePath(basePath, virtualPath)); - } - - public string ReadAllText(string basePath, string virtualPath) - { - return File.ReadAllText(GetResourcePath(basePath, virtualPath)); - } - - private 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 in Path.GetFullPath"); - } - - // 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 08bb39578..f2cdfeb16 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -1,57 +1,75 @@ +#nullable disable + +#pragma warning disable CS1591 + 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.Events; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Class ScheduledTaskWorker + /// Class ScheduledTaskWorker. /// </summary> public class ScheduledTaskWorker : IScheduledTaskWorker { - public event EventHandler<GenericEventArgs<double>> TaskProgress; - - /// <summary> - /// Gets or sets the scheduled task. - /// </summary> - /// <value>The scheduled task.</value> - public IScheduledTask ScheduledTask { get; private set; } - /// <summary> - /// Gets or sets the json serializer. + /// The options for the json Serializer. /// </summary> - /// <value>The json serializer.</value> - private IJsonSerializer JsonSerializer { get; set; } + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; /// <summary> /// Gets or sets the application paths. /// </summary> /// <value>The application paths.</value> - private IApplicationPaths ApplicationPaths { get; set; } + private readonly IApplicationPaths _applicationPaths; /// <summary> - /// Gets the logger. + /// Gets or sets the logger. /// </summary> /// <value>The logger.</value> - private ILogger Logger { get; set; } + private readonly ILogger _logger; /// <summary> - /// Gets the task manager. + /// Gets or sets the task manager. /// </summary> /// <value>The task manager.</value> - private ITaskManager TaskManager { get; set; } - private readonly IFileSystem _fileSystem; + private readonly ITaskManager _taskManager; + + /// <summary> + /// The _last execution result sync lock. + /// </summary> + private readonly object _lastExecutionResultSyncLock = new object(); + + private bool _readFromFile = false; + + /// <summary> + /// The _last execution result. + /// </summary> + private TaskResult _lastExecutionResult; + + private Task _currentTask; + + /// <summary> + /// The _triggers. + /// </summary> + private Tuple<TaskTriggerInfo, ITaskTrigger>[] _triggers; + + /// <summary> + /// The _id. + /// </summary> + private string _id; /// <summary> /// Initializes a new instance of the <see cref="ScheduledTaskWorker" /> class. @@ -59,7 +77,6 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <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 @@ -70,50 +87,46 @@ namespace Emby.Server.Implementations.ScheduledTasks /// or /// jsonSerializer /// or - /// logger + /// logger. /// </exception> - public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, IJsonSerializer jsonSerializer, ILogger logger, IFileSystem fileSystem) + public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger) { if (scheduledTask == null) { throw new ArgumentNullException(nameof(scheduledTask)); } + if (applicationPaths == null) { throw new ArgumentNullException(nameof(applicationPaths)); } + if (taskManager == null) { throw new ArgumentNullException(nameof(taskManager)); } - if (jsonSerializer == null) - { - throw new ArgumentNullException(nameof(jsonSerializer)); - } + if (logger == null) { throw new ArgumentNullException(nameof(logger)); } ScheduledTask = scheduledTask; - ApplicationPaths = applicationPaths; - TaskManager = taskManager; - JsonSerializer = jsonSerializer; - Logger = logger; - _fileSystem = fileSystem; + _applicationPaths = applicationPaths; + _taskManager = taskManager; + _logger = logger; InitTriggerEvents(); } - private bool _readFromFile = false; - /// <summary> - /// The _last execution result - /// </summary> - private TaskResult _lastExecutionResult; + public event EventHandler<GenericEventArgs<double>> TaskProgress; + /// <summary> - /// The _last execution result sync lock + /// Gets the scheduled task. /// </summary> - private readonly object _lastExecutionResultSyncLock = new object(); + /// <value>The scheduled task.</value> + public IScheduledTask ScheduledTask { get; private set; } + /// <summary> /// Gets the last execution result. /// </summary> @@ -130,21 +143,31 @@ 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); } } + _readFromFile = true; } } return _lastExecutionResult; } + private set { _lastExecutionResult = value; @@ -154,7 +177,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); } } } @@ -178,7 +203,7 @@ namespace Emby.Server.Implementations.ScheduledTasks public string Category => ScheduledTask.Category; /// <summary> - /// Gets the current cancellation token + /// Gets or sets the current cancellation token. /// </summary> /// <value>The current cancellation token source.</value> private CancellationTokenSource CurrentCancellationTokenSource { get; set; } @@ -215,11 +240,7 @@ namespace Emby.Server.Implementations.ScheduledTasks public double? CurrentProgress { get; private set; } /// <summary> - /// The _triggers - /// </summary> - private Tuple<TaskTriggerInfo, ITaskTrigger>[] _triggers; - /// <summary> - /// Gets the triggers that define when the task will run + /// Gets or sets the triggers that define when the task will run. /// </summary> /// <value>The triggers.</value> private Tuple<TaskTriggerInfo, ITaskTrigger>[] InternalTriggers @@ -245,10 +266,10 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Gets the triggers that define when the task will run + /// Gets or sets the triggers that define when the task will run. /// </summary> /// <value>The triggers.</value> - /// <exception cref="ArgumentNullException">value</exception> + /// <exception cref="ArgumentNullException"><c>value</c> is <c>null</c>.</exception> public TaskTriggerInfo[] Triggers { get @@ -256,6 +277,7 @@ namespace Emby.Server.Implementations.ScheduledTasks var triggers = InternalTriggers; return triggers.Select(i => i.Item1).ToArray(); } + set { if (value == null) @@ -273,11 +295,6 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// The _id - /// </summary> - private string _id; - - /// <summary> /// Gets the unique id. /// </summary> /// <value>The unique id.</value> @@ -285,12 +302,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { get { - if (_id == null) - { - _id = ScheduledTask.GetType().FullName.GetMD5().ToString("N"); - } - - return _id; + return _id ??= ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture); } } @@ -317,9 +329,9 @@ namespace Emby.Server.Implementations.ScheduledTasks trigger.Stop(); - trigger.Triggered -= trigger_Triggered; - trigger.Triggered += trigger_Triggered; - trigger.Start(LastExecutionResult, Logger, Name, isApplicationStartup); + trigger.Triggered -= OnTriggerTriggered; + trigger.Triggered += OnTriggerTriggered; + trigger.Start(LastExecutionResult, _logger, Name, isApplicationStartup); } } @@ -328,36 +340,32 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param> - async void trigger_Triggered(object sender, EventArgs e) + private async void OnTriggerTriggered(object sender, EventArgs e) { var trigger = (ITaskTrigger)sender; - var configurableTask = ScheduledTask as IConfigurableScheduledTask; - - if (configurableTask != null && !configurableTask.IsEnabled) + if (ScheduledTask is IConfigurableScheduledTask configurableTask && !configurableTask.IsEnabled) { return; } - Logger.LogInformation("{0} fired for task: {1}", trigger.GetType().Name, Name); + _logger.LogInformation("{0} fired for task: {1}", trigger.GetType().Name, Name); trigger.Stop(); - TaskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions); + _taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions); await Task.Delay(1000).ConfigureAwait(false); - trigger.Start(LastExecutionResult, Logger, Name, false); + trigger.Start(LastExecutionResult, _logger, Name, false); } - private Task _currentTask; - /// <summary> - /// Executes the task + /// Executes the task. /// </summary> /// <param name="options">Task options.</param> /// <returns>Task.</returns> - /// <exception cref="InvalidOperationException">Cannot execute a Task that is already running</exception> + /// <exception cref="InvalidOperationException">Cannot execute a Task that is already running.</exception> public async Task Execute(TaskOptions options) { var task = Task.Run(async () => await ExecuteInternal(options).ConfigureAwait(false)); @@ -387,11 +395,11 @@ namespace Emby.Server.Implementations.ScheduledTasks CurrentCancellationTokenSource = new CancellationTokenSource(); - Logger.LogInformation("Executing {0}", Name); + _logger.LogInformation("Executing {0}", Name); - ((TaskManager)TaskManager).OnTaskExecuting(this); + ((TaskManager)_taskManager).OnTaskExecuting(this); - progress.ProgressChanged += progress_ProgressChanged; + progress.ProgressChanged += OnProgressChanged; TaskCompletionStatus status; CurrentExecutionStartTime = DateTime.UtcNow; @@ -415,7 +423,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } catch (Exception ex) { - Logger.LogError(ex, "Error"); + _logger.LogError(ex, "Error"); failureException = ex; @@ -425,7 +433,7 @@ namespace Emby.Server.Implementations.ScheduledTasks var startTime = CurrentExecutionStartTime; var endTime = DateTime.UtcNow; - progress.ProgressChanged -= progress_ProgressChanged; + progress.ProgressChanged -= OnProgressChanged; CurrentCancellationTokenSource.Dispose(); CurrentCancellationTokenSource = null; CurrentProgress = null; @@ -438,20 +446,17 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> /// <param name="sender">The sender.</param> /// <param name="e">The e.</param> - void progress_ProgressChanged(object sender, double e) + private void OnProgressChanged(object sender, double e) { e = Math.Min(e, 100); CurrentProgress = e; - TaskProgress?.Invoke(this, new GenericEventArgs<double> - { - Argument = e - }); + TaskProgress?.Invoke(this, new GenericEventArgs<double>(e)); } /// <summary> - /// Stops the task if it is currently executing + /// Stops the task if it is currently executing. /// </summary> /// <exception cref="InvalidOperationException">Cannot cancel a Task unless it is in the Running state.</exception> public void Cancel() @@ -471,7 +476,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { if (State == TaskState.Running) { - Logger.LogInformation("Attempting to cancel Scheduled Task {0}", Name); + _logger.LogInformation("Attempting to cancel Scheduled Task {0}", Name); CurrentCancellationTokenSource.Cancel(); } } @@ -482,7 +487,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <returns>System.String.</returns> private string GetScheduledTasksConfigurationDirectory() { - return Path.Combine(ApplicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"); + return Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"); } /// <summary> @@ -491,7 +496,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <returns>System.String.</returns> private string GetScheduledTasksDataDirectory() { - return Path.Combine(ApplicationPaths.DataPath, "ScheduledTasks"); + return Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"); } /// <summary> @@ -530,7 +535,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. @@ -565,8 +571,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> @@ -575,11 +582,12 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="startTime">The start time.</param> /// <param name="endTime">The end time.</param> /// <param name="status">The status.</param> + /// <param name="ex">The exception.</param> private void OnTaskCompleted(DateTime startTime, DateTime endTime, TaskCompletionStatus status, Exception ex) { var elapsedTime = endTime - startTime; - Logger.LogInformation("{0} {1} after {2} minute(s) and {3} seconds", Name, status, Math.Truncate(elapsedTime.TotalMinutes), elapsedTime.Seconds); + _logger.LogInformation("{0} {1} after {2} minute(s) and {3} seconds", Name, status, Math.Truncate(elapsedTime.TotalMinutes), elapsedTime.Seconds); var result = new TaskResult { @@ -600,7 +608,7 @@ namespace Emby.Server.Implementations.ScheduledTasks LastExecutionResult = result; - ((TaskManager)TaskManager).OnTaskCompleted(this, result); + ((TaskManager)_taskManager).OnTaskCompleted(this, result); } /// <summary> @@ -609,6 +617,7 @@ namespace Emby.Server.Implementations.ScheduledTasks public void Dispose() { Dispose(true); + GC.SuppressFinalize(this); } /// <summary> @@ -629,34 +638,35 @@ namespace Emby.Server.Implementations.ScheduledTasks { try { - Logger.LogInformation(Name + ": Cancelling"); + _logger.LogInformation(Name + ": Cancelling"); token.Cancel(); } catch (Exception ex) { - Logger.LogError(ex, "Error calling CancellationToken.Cancel();"); + _logger.LogError(ex, "Error calling CancellationToken.Cancel();"); } } + var task = _currentTask; if (task != null) { try { - Logger.LogInformation(Name + ": Waiting on Task"); - var exited = Task.WaitAll(new[] { task }, 2000); + _logger.LogInformation(Name + ": Waiting on Task"); + var exited = task.Wait(2000); if (exited) { - Logger.LogInformation(Name + ": Task exited"); + _logger.LogInformation(Name + ": Task exited"); } else { - Logger.LogInformation(Name + ": Timed out waiting for task to stop"); + _logger.LogInformation(Name + ": Timed out waiting for task to stop"); } } catch (Exception ex) { - Logger.LogError(ex, "Error calling Task.WaitAll();"); + _logger.LogError(ex, "Error calling Task.WaitAll();"); } } @@ -664,14 +674,15 @@ namespace Emby.Server.Implementations.ScheduledTasks { try { - Logger.LogDebug(Name + ": Disposing CancellationToken"); + _logger.LogDebug(Name + ": Disposing CancellationToken"); token.Dispose(); } catch (Exception ex) { - Logger.LogError(ex, "Error calling CancellationToken.Dispose();"); + _logger.LogError(ex, "Error calling CancellationToken.Dispose();"); } } + if (wassRunning) { OnTaskCompleted(startTime, DateTime.UtcNow, TaskCompletionStatus.Aborted, null); @@ -680,12 +691,11 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Converts a TaskTriggerInfo into a concrete BaseTaskTrigger + /// Converts a TaskTriggerInfo into a concrete BaseTaskTrigger. /// </summary> /// <param name="info">The info.</param> /// <returns>BaseTaskTrigger.</returns> - /// <exception cref="ArgumentNullException"></exception> - /// <exception cref="ArgumentException">Invalid trigger type: + info.Type</exception> + /// <exception cref="ArgumentException">Invalid trigger type: + info.Type.</exception> private ITaskTrigger GetTrigger(TaskTriggerInfo info) { var options = new TaskOptions @@ -693,21 +703,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) { @@ -719,45 +725,36 @@ 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); } /// <summary> - /// Disposes each trigger + /// Disposes each trigger. /// </summary> private void DisposeTriggers() { foreach (var triggerInfo in InternalTriggers) { var trigger = triggerInfo.Item2; - trigger.Triggered -= trigger_Triggered; + trigger.Triggered -= OnTriggerTriggered; trigger.Stop(); } } diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 595c3037d..0431858fc 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -1,84 +1,65 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Data.Events; using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Class TaskManager + /// Class TaskManager. /// </summary> public class TaskManager : ITaskManager { - public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; - public event EventHandler<TaskCompletionEventArgs> TaskCompleted; - - /// <summary> - /// Gets the list of Scheduled Tasks - /// </summary> - /// <value>The scheduled tasks.</value> - public IScheduledTaskWorker[] ScheduledTasks { get; private set; } - /// <summary> - /// The _task queue + /// The _task queue. /// </summary> private readonly ConcurrentQueue<Tuple<Type, TaskOptions>> _taskQueue = new ConcurrentQueue<Tuple<Type, TaskOptions>>(); - /// <summary> - /// Gets or sets the json serializer. - /// </summary> - /// <value>The json serializer.</value> - private IJsonSerializer JsonSerializer { get; set; } - - /// <summary> - /// Gets or sets the application paths. - /// </summary> - /// <value>The application paths.</value> - private IApplicationPaths ApplicationPaths { get; set; } - - /// <summary> - /// Gets the logger. - /// </summary> - /// <value>The logger.</value> - private ILogger Logger { get; set; } - private readonly IFileSystem _fileSystem; + private readonly IApplicationPaths _applicationPaths; + private readonly ILogger<TaskManager> _logger; /// <summary> /// 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="loggerFactory">The logger factory.</param> - /// <exception cref="System.ArgumentException">kernel</exception> + /// <param name="logger">The logger.</param> public TaskManager( IApplicationPaths applicationPaths, - IJsonSerializer jsonSerializer, - ILoggerFactory loggerFactory, - IFileSystem fileSystem) + ILogger<TaskManager> logger) { - ApplicationPaths = applicationPaths; - JsonSerializer = jsonSerializer; - Logger = loggerFactory.CreateLogger(nameof(TaskManager)); - _fileSystem = fileSystem; + _applicationPaths = applicationPaths; + _logger = logger; - ScheduledTasks = new IScheduledTaskWorker[] { }; + ScheduledTasks = Array.Empty<IScheduledTaskWorker>(); } + public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting; + + public event EventHandler<TaskCompletionEventArgs> TaskCompleted; + + /// <summary> + /// Gets the list of Scheduled Tasks. + /// </summary> + /// <value>The scheduled tasks.</value> + public IScheduledTaskWorker[] ScheduledTasks { get; private set; } + /// <summary> /// Cancels if running and queue. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The task type.</typeparam> /// <param name="options">Task options.</param> public void CancelIfRunningAndQueue<T>(TaskOptions options) - where T : IScheduledTask + where T : IScheduledTask { var task = ScheduledTasks.First(t => t.ScheduledTask.GetType() == typeof(T)); ((ScheduledTaskWorker)task).CancelIfRunning(); @@ -93,9 +74,9 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Cancels if running + /// Cancels if running. /// </summary> - /// <typeparam name="T"></typeparam> + /// <typeparam name="T">The task type.</typeparam> public void CancelIfRunning<T>() where T : IScheduledTask { @@ -106,8 +87,8 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <summary> /// Queues the scheduled task. /// </summary> - /// <typeparam name="T"></typeparam> - /// <param name="options">Task options</param> + /// <typeparam name="T">The task type.</typeparam> + /// <param name="options">Task options.</param> public void QueueScheduledTask<T>(TaskOptions options) where T : IScheduledTask { @@ -115,7 +96,7 @@ namespace Emby.Server.Implementations.ScheduledTasks if (scheduledTask == null) { - Logger.LogError("Unable to find scheduled task of type {0} in QueueScheduledTask.", typeof(T).Name); + _logger.LogError("Unable to find scheduled task of type {0} in QueueScheduledTask.", typeof(T).Name); } else { @@ -147,13 +128,13 @@ namespace Emby.Server.Implementations.ScheduledTasks if (scheduledTask == null) { - Logger.LogError("Unable to find scheduled task of type {0} in Execute.", typeof(T).Name); + _logger.LogError("Unable to find scheduled task of type {0} in Execute.", typeof(T).Name); } else { var type = scheduledTask.ScheduledTask.GetType(); - Logger.LogInformation("Queueing task {0}", type.Name); + _logger.LogInformation("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -176,7 +157,7 @@ namespace Emby.Server.Implementations.ScheduledTasks if (scheduledTask == null) { - Logger.LogError("Unable to find scheduled task of type {0} in QueueScheduledTask.", task.GetType().Name); + _logger.LogError("Unable to find scheduled task of type {0} in QueueScheduledTask.", task.GetType().Name); } else { @@ -193,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) { @@ -213,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, _fileSystem)); + var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger)); ScheduledTasks = ScheduledTasks.Concat(list).ToArray(); } @@ -224,6 +205,7 @@ namespace Emby.Server.Implementations.ScheduledTasks public void Dispose() { Dispose(true); + GC.SuppressFinalize(this); } /// <summary> @@ -254,10 +236,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="task">The task.</param> internal void OnTaskExecuting(IScheduledTaskWorker task) { - TaskExecuting?.Invoke(this, new GenericEventArgs<IScheduledTaskWorker> - { - Argument = task - }); + TaskExecuting?.Invoke(this, new GenericEventArgs<IScheduledTaskWorker>(task)); } /// <summary> @@ -267,11 +246,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// <param name="result">The result.</param> internal void OnTaskCompleted(IScheduledTaskWorker task, TaskResult result) { - TaskCompleted?.Invoke(task, new TaskCompletionEventArgs - { - Result = result, - Task = task - }); + TaskCompleted?.Invoke(task, new TaskCompletionEventArgs(task, result)); ExecuteQueuedTasks(); } @@ -281,7 +256,7 @@ namespace Emby.Server.Implementations.ScheduledTasks /// </summary> private void ExecuteQueuedTasks() { - Logger.LogInformation("ExecuteQueuedTasks"); + _logger.LogInformation("ExecuteQueuedTasks"); // Execute queued tasks lock (_taskQueue) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index 2f07ff15a..09ea6271d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -12,23 +12,19 @@ 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 Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Class ChapterImagesTask + /// Class ChapterImagesTask. /// </summary> public class ChapterImagesTask : IScheduledTask { /// <summary> - /// The _logger - /// </summary> - private readonly ILogger _logger; - /// <summary> - /// The _library manager + /// The _library manager. /// </summary> private readonly ILibraryManager _libraryManager; @@ -38,27 +34,50 @@ namespace Emby.Server.Implementations.ScheduledTasks private readonly IEncodingManager _encodingManager; private readonly IFileSystem _fileSystem; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="ChapterImagesTask" /> class. /// </summary> - public ChapterImagesTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, IItemRepository itemRepo, IApplicationPaths appPaths, IEncodingManager encodingManager, IFileSystem fileSystem) + /// <param name="libraryManager">The library manager.</param>. + /// <param name="itemRepo">The item repository.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="encodingManager">The encoding manager.</param> + /// <param name="fileSystem">The filesystem.</param> + /// <param name="localization">The localization manager.</param> + public ChapterImagesTask( + ILibraryManager libraryManager, + IItemRepository itemRepo, + IApplicationPaths appPaths, + IEncodingManager encodingManager, + IFileSystem fileSystem, + ILocalizationManager localization) { - _logger = loggerFactory.CreateLogger(GetType().Name); _libraryManager = libraryManager; _itemRepo = itemRepo; _appPaths = appPaths; _encodingManager = encodingManager; _fileSystem = fileSystem; + _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[] { - + return new[] + { new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerDaily, @@ -69,7 +88,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Returns the task to be executed + /// Returns the task to be executed. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> @@ -88,7 +107,6 @@ namespace Emby.Server.Implementations.ScheduledTasks SourceTypes = new SourceType[] { SourceType.Library }, HasChapterImages = false, IsVirtualItem = false - }) .OfType<Video>() .ToList(); @@ -104,7 +122,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { previouslyFailedImages = File.ReadAllText(failHistoryPath) - .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + .Split('|', StringSplitOptions.RemoveEmptyEntries) .ToList(); } catch (IOException) @@ -117,7 +135,7 @@ namespace Emby.Server.Implementations.ScheduledTasks previouslyFailedImages = new List<string>(); } - var directoryService = new DirectoryService(_logger, _fileSystem); + var directoryService = new DirectoryService(_fileSystem); foreach (var video in videos) { @@ -138,10 +156,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); } @@ -153,24 +173,10 @@ namespace Emby.Server.Implementations.ScheduledTasks } catch (ObjectDisposedException) { - //TODO Investigate and properly fix. + // TODO Investigate and properly fix. break; } } } - - public string Name => "Chapter image extraction"; - - public string Description => "Creates thumbnails for videos that have chapters."; - - public string Category => "Library"; - - public string Key => "RefreshChapterImages"; - - public bool IsHidden => false; - - public bool IsEnabled => true; - - 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..79886cb52 --- /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 6ec83b5c0..a575b260c 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs @@ -5,6 +5,7 @@ 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; @@ -12,7 +13,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks { /// <summary> - /// Deletes old cache files + /// Deletes old cache files. /// </summary> public class DeleteCacheFileTask : IScheduledTask, IConfigurableScheduledTask { @@ -20,37 +21,66 @@ 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 ILogger _logger; - + private readonly IApplicationPaths _applicationPaths; + private readonly ILogger<DeleteCacheFileTask> _logger; private readonly IFileSystem _fileSystem; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="DeleteCacheFileTask" /> class. /// </summary> - public DeleteCacheFileTask(IApplicationPaths appPaths, ILogger logger, IFileSystem fileSystem) + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public DeleteCacheFileTask( + IApplicationPaths appPaths, + ILogger<DeleteCacheFileTask> logger, + 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 + /// 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 } }; } /// <summary> - /// Returns the task to be executed + /// Returns the task to be executed. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> @@ -61,7 +91,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks try { - DeleteCacheFilesFromDirectory(cancellationToken, ApplicationPaths.CachePath, minDateModified, progress); + DeleteCacheFilesFromDirectory(cancellationToken, _applicationPaths.CachePath, minDateModified, progress); } catch (DirectoryNotFoundException) { @@ -74,7 +104,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks try { - DeleteCacheFilesFromDirectory(cancellationToken, ApplicationPaths.TempDirectory, minDateModified, progress); + DeleteCacheFilesFromDirectory(cancellationToken, _applicationPaths.TempDirectory, minDateModified, progress); } catch (DirectoryNotFoundException) { @@ -84,9 +114,8 @@ 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 + /// Deletes the cache files from directory with a last write time less than a given date. /// </summary> /// <param name="cancellationToken">The task cancellation token.</param> /// <param name="directory">The directory.</param> @@ -157,19 +186,5 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks _logger.LogError(ex, "Error deleting file {path}", path); } } - - public string Name => "Cache file cleanup"; - - public string Description => "Deletes cache files no longer needed by the system"; - - public string Category => "Maintenance"; - - public string Key => "DeleteCacheFiles"; - - public bool IsHidden => false; - - public bool IsEnabled => true; - - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs index e468c301a..fedb5deb0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs @@ -1,52 +1,76 @@ using System; using System.Collections.Generic; +using System.Globalization; 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; namespace Emby.Server.Implementations.ScheduledTasks.Tasks { /// <summary> - /// Deletes old log files + /// Deletes old log files. /// </summary> public class DeleteLogFileTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Gets or sets the configuration manager. - /// </summary> - /// <value>The configuration manager.</value> - private IConfigurationManager ConfigurationManager { get; set; } - + private readonly IConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="DeleteLogFileTask" /> class. /// </summary> /// <param name="configurationManager">The configuration manager.</param> - public DeleteLogFileTask(IConfigurationManager configurationManager, IFileSystem fileSystem) + /// <param name="fileSystem">The file system.</param> + /// <param name="localization">The localization manager.</param> + public DeleteLogFileTask(IConfigurationManager configurationManager, IFileSystem fileSystem, ILocalizationManager localization) { - ConfigurationManager = configurationManager; + _configurationManager = configurationManager; _fileSystem = fileSystem; + _localization = localization; } + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanLogs"); + + /// <inheritdoc /> + public string Description => string.Format( + CultureInfo.InvariantCulture, + _localization.GetLocalizedString("TaskCleanLogsDescription"), + _configurationManager.CommonConfiguration.LogFileRetentionDays); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => "CleanLogFiles"; + + /// <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 + /// 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} + return new[] + { + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } }; } /// <summary> - /// Returns the task to be executed + /// Returns the task to be executed. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> @@ -54,12 +78,13 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) { // Delete log files more than n days old - var minDateModified = DateTime.UtcNow.AddDays(-ConfigurationManager.CommonConfiguration.LogFileRetentionDays); + 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; @@ -80,19 +105,5 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks return Task.CompletedTask; } - - public string Name => "Log file cleanup"; - - public string Description => string.Format("Deletes log files that are more than {0} days old.", ConfigurationManager.CommonConfiguration.LogFileRetentionDays); - - public string Category => "Maintenance"; - - public string Key => "CleanLogFiles"; - - public bool IsHidden => false; - - public bool IsEnabled => true; - - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs new file mode 100644 index 000000000..b13fc7fc6 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.IO; +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; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks +{ + /// <summary> + /// Deletes all transcoding temp files. + /// </summary> + public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly ILogger<DeleteTranscodeFileTask> _logger; + private readonly IConfigurationManager _configurationManager; + private readonly IFileSystem _fileSystem; + private readonly ILocalizationManager _localization; + + /// <summary> + /// 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, + IConfigurationManager configurationManager, + ILocalizationManager localization) + { + _logger = logger; + _fileSystem = fileSystem; + _configurationManager = configurationManager; + _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() + { + return new[] + { + 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) + { + var minDateModified = DateTime.UtcNow.AddDays(-1); + progress.Report(50); + + DeleteTempFilesFromDirectory(cancellationToken, _configurationManager.GetTranscodePath(), minDateModified, progress); + + return Task.CompletedTask; + } + + /// <summary> + /// Deletes the transcoded temp files from directory with a last write time less than a given date. + /// </summary> + /// <param name="cancellationToken">The task cancellation token.</param> + /// <param name="directory">The directory.</param> + /// <param name="minDateModified">The min date modified.</param> + /// <param name="progress">The progress.</param> + private void DeleteTempFilesFromDirectory(CancellationToken cancellationToken, string directory, DateTime minDateModified, IProgress<double> progress) + { + var filesToDelete = _fileSystem.GetFiles(directory, true) + .Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) + .ToList(); + + var index = 0; + + foreach (var file in filesToDelete) + { + double percent = index; + percent /= filesToDelete.Count; + + progress.Report(100 * percent); + + cancellationToken.ThrowIfCancellationRequested(); + + DeleteFile(file.FullName); + + index++; + } + + DeleteEmptyFolders(directory); + + progress.Report(100); + } + + private void DeleteEmptyFolders(string parent) + { + foreach (var directory in _fileSystem.GetDirectoryPaths(parent)) + { + DeleteEmptyFolders(directory); + if (!_fileSystem.GetFileSystemEntryPaths(directory).Any()) + { + try + { + Directory.Delete(directory, false); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Error deleting directory {path}", directory); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting directory {path}", directory); + } + } + } + } + + private void DeleteFile(string path) + { + try + { + _fileSystem.DeleteFile(path); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Error deleting file {path}", path); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting file {path}", path); + } + } + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs new file mode 100644 index 000000000..35a4aeef6 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -0,0 +1,104 @@ +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> + /// <param name="logger">The logger.</param> + /// <param name="localization">The localization manager.</param> + /// <param name="provider">The jellyfin DB context provider.</param> + 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 d70799c47..53c692a46 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -1,44 +1,59 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Class PeopleValidationTask + /// Class PeopleValidationTask. /// </summary> public class PeopleValidationTask : IScheduledTask { /// <summary> - /// The _library manager + /// The library manager. /// </summary> private readonly ILibraryManager _libraryManager; - - private readonly IServerApplicationHost _appHost; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> - /// <param name="appHost">The server application host</param> - public PeopleValidationTask(ILibraryManager libraryManager, IServerApplicationHost appHost) + /// <param name="localization">The localization manager.</param> + public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization) { _libraryManager = libraryManager; - _appHost = appHost; + _localization = localization; } + public string Name => _localization.GetLocalizedString("TaskRefreshPeople"); + + public string Description => _localization.GetLocalizedString("TaskRefreshPeopleDescription"); + + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + public string Key => "RefreshPeople"; + + public bool IsHidden => false; + + public bool IsEnabled => true; + + public bool IsLogged => true; + /// <summary> - /// Creates the triggers that define when the task will run + /// Creates the triggers that define when the task will run. /// </summary> + /// <returns>An <see cref="IEnumerable{TaskTriggerInfo}"/> containing the default trigger infos for this task.</returns> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] { - // Every so often new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, @@ -48,7 +63,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Returns the task to be executed + /// Returns the task to be executed. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> @@ -57,19 +72,5 @@ namespace Emby.Server.Implementations.ScheduledTasks { return _libraryManager.ValidatePeople(cancellationToken, progress); } - - public string Name => "Refresh people"; - - public string Description => "Updates metadata for actors and directors in your media library."; - - public string Category => "Library"; - - public string Key => "RefreshPeople"; - - public bool IsHidden => false; - - public bool IsEnabled => true; - - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index c6431c311..11a5fb79f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -1,66 +1,85 @@ -using MediaBrowser.Common; -using MediaBrowser.Common.Updates; -using MediaBrowser.Model.Net; +#pragma warning disable CS1591 + 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.Progress; +using MediaBrowser.Common.Updates; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Plugin Update Task + /// Plugin Update Task. /// </summary> public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask { /// <summary> - /// The _logger + /// The _logger. /// </summary> - private readonly ILogger _logger; + private readonly ILogger<PluginUpdateTask> _logger; private readonly IInstallationManager _installationManager; + private readonly ILocalizationManager _localization; - private readonly IApplicationHost _appHost; - - public PluginUpdateTask(ILogger logger, IInstallationManager installationManager, IApplicationHost appHost) + public PluginUpdateTask(ILogger<PluginUpdateTask> logger, IInstallationManager installationManager, ILocalizationManager localization) { _logger = logger; _installationManager = installationManager; - _appHost = appHost; + _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 + /// Creates the triggers that define when the task will run. /// </summary> /// <returns>IEnumerable{BaseTaskTrigger}.</returns> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { - return new[] { - - // At startup - new TaskTriggerInfo {Type = TaskTriggerInfo.TriggerStartup}, - - // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks} - }; + // At startup + yield return new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerStartup }; + + // Every so often + yield return new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks }; } /// <summary> - /// Update installed plugins + /// Update installed plugins. /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> + /// <returns><see cref="Task" />.</returns> public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) { progress.Report(0); - var packagesToInstall = (await _installationManager.GetAvailablePluginUpdates(typeof(PluginUpdateTask).Assembly.GetName().Version, true, cancellationToken).ConfigureAwait(false)).ToList(); + var packageFetchTask = _installationManager.GetAvailablePluginUpdates(cancellationToken); + var packagesToInstall = (await packageFetchTask.ConfigureAwait(false)).ToList(); progress.Report(10); @@ -72,7 +91,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { - await _installationManager.InstallPackage(package, true, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + await _installationManager.InstallPackage(package, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -82,38 +101,23 @@ namespace Emby.Server.Implementations.ScheduledTasks throw; } } - catch (HttpException ex) + catch (HttpRequestException ex) { - _logger.LogError(ex, "Error downloading {0}", package.name); + _logger.LogError(ex, "Error downloading {0}", package.Name); } catch (IOException ex) { - _logger.LogError(ex, "Error updating {0}", package.name); + _logger.LogError(ex, "Error updating {0}", package.Name); } // Update progress lock (progress) { - numComplete++; - progress.Report(90.0 * numComplete / packagesToInstall.Count + 10); + progress.Report((90.0 * ++numComplete / packagesToInstall.Count) + 10); } } progress.Report(100); } - - public string Name => "Check for plugin updates"; - - public string Description => "Downloads and installs updates for plugins that are configured to update automatically."; - - public string Category => "Application"; - - public string Key => "PluginUpdates"; - - public bool IsHidden => false; - - public bool IsEnabled => true; - - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs index 1a3d85ad7..2184b3d03 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs @@ -1,45 +1,60 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Library; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Class RefreshMediaLibraryTask + /// Class RefreshMediaLibraryTask. /// </summary> public class RefreshMediaLibraryTask : IScheduledTask { /// <summary> - /// The _library manager + /// The _library manager. /// </summary> private readonly ILibraryManager _libraryManager; - private readonly IServerConfigurationManager _config; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="RefreshMediaLibraryTask" /> class. /// </summary> /// <param name="libraryManager">The library manager.</param> - public RefreshMediaLibraryTask(ILibraryManager libraryManager, IServerConfigurationManager config) + /// <param name="localization">The localization manager.</param> + public RefreshMediaLibraryTask(ILibraryManager libraryManager, ILocalizationManager localization) { _libraryManager = libraryManager; - _config = config; + _localization = localization; } + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskRefreshLibrary"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskRefreshLibraryDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + /// <inheritdoc /> + public string Key => "RefreshLibrary"; + /// <summary> - /// Creates the triggers that define when the task will run + /// 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(12).Ticks} + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(12).Ticks }; } @@ -57,19 +72,5 @@ namespace Emby.Server.Implementations.ScheduledTasks return ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken); } - - public string Name => "Scan media library"; - - public string Description => "Scans your media library and refreshes metatata based on configuration."; - - public string Category => "Library"; - - public string Key => "RefreshLibrary"; - - public bool IsHidden => false; - - public bool IsEnabled => true; - - public bool IsLogged => true; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs index 98685cebe..29ab6a73d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using System.Threading; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -7,31 +6,40 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Represents a task trigger that fires everyday + /// 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 + /// Stars waiting for the trigger action. /// </summary> /// <param name="lastResult">The last result.</param> + /// <param name="logger">The logger.</param> + /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> public void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) { @@ -39,18 +47,18 @@ 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 {0} set to fire at {1}, which is {2} minutes from now.", taskName, triggerDate.ToString(), dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture)); + 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> - /// Stops waiting for the trigger action + /// Stops waiting for the trigger action. /// </summary> public void Stop() { @@ -62,26 +70,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/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs index 3a34da3af..30568e809 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs @@ -7,33 +7,41 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Represents a task trigger that runs repeatedly on an interval + /// 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 + /// Stars waiting for the trigger action. /// </summary> /// <param name="lastResult">The last result.</param> + /// <param name="logger">The logger.</param> + /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> public void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) { @@ -48,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) @@ -64,11 +72,11 @@ 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> - /// Stops waiting for the trigger action + /// Stops waiting for the trigger action. /// </summary> public void Stop() { @@ -80,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 08ff4f55f..18b9a8b75 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; @@ -6,26 +8,37 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Class StartupTaskTrigger + /// 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> - /// Stars waiting for the trigger action + /// 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> + /// <param name="logger">The logger.</param> + /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> public async void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) { @@ -38,26 +51,18 @@ namespace Emby.Server.Implementations.ScheduledTasks } /// <summary> - /// Stops waiting for the trigger action + /// Stops waiting for the trigger action. /// </summary> public void Stop() { } /// <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 2a6a7b13c..36ae190b0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs @@ -6,37 +6,43 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// <summary> - /// Represents a task trigger that fires on a weekly basis + /// 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 + /// Stars waiting for the trigger action. /// </summary> /// <param name="lastResult">The last result.</param> + /// <param name="logger">The logger.</param> + /// <param name="taskName">The name of the task.</param> /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> public void Start(TaskResult lastResult, ILogger logger, string taskName, bool isApplicationStartup) { @@ -44,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> @@ -56,26 +62,26 @@ 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> - /// Stops waiting for the trigger action + /// Stops waiting for the trigger action. /// </summary> public void Stop() { @@ -87,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 deleted file mode 100644 index c81a93767..000000000 --- a/Emby.Server.Implementations/Security/AuthenticationRepository.cs +++ /dev/null @@ -1,427 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using Emby.Server.Implementations.Data; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Security; -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Querying; -using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Security -{ - public class AuthenticationRepository : BaseSqliteRepository, IAuthenticationRepository - { - private readonly IServerConfigurationManager _config; - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); - - public AuthenticationRepository(ILoggerFactory loggerFactory, IServerConfigurationManager config) - : base(loggerFactory.CreateLogger(nameof(AuthenticationRepository))) - { - _config = config; - DbFilePath = Path.Combine(config.ApplicationPaths.DataPath, "authentication.db"); - } - - public void Initialize() - { - using (var connection = CreateConnection()) - { - RunDefaultInitialization(connection); - - var tableNewlyCreated = !TableExists(connection, "Tokens"); - - string[] queries = { - - "create table if not exists Tokens (Id INTEGER PRIMARY KEY, AccessToken TEXT NOT NULL, DeviceId TEXT NOT NULL, AppName TEXT NOT NULL, AppVersion TEXT NOT NULL, DeviceName TEXT NOT NULL, UserId TEXT, UserName TEXT, IsActive BIT NOT NULL, DateCreated DATETIME NOT NULL, DateLastActivity DATETIME NOT NULL)", - "create table if not exists Devices (Id TEXT NOT NULL PRIMARY KEY, CustomName TEXT, Capabilities TEXT)", - - "drop index if exists idx_AccessTokens", - "drop index if exists Tokens1", - "drop index if exists Tokens2", - "create index if not exists Tokens3 on Tokens (AccessToken, DateLastActivity)", - "create index if not exists Tokens4 on Tokens (Id, DateLastActivity)", - "create index if not exists Devices1 on Devices (Id)" - }; - - connection.RunQueries(queries); - - TryMigrate(connection, tableNewlyCreated); - } - } - - private void TryMigrate(ManagedConnection connection, bool tableNewlyCreated) - { - try - { - if (tableNewlyCreated && TableExists(connection, "AccessTokens")) - { - connection.RunInTransaction(db => - { - var existingColumnNames = GetColumnNames(db, "AccessTokens"); - - AddColumn(db, "AccessTokens", "UserName", "TEXT", existingColumnNames); - AddColumn(db, "AccessTokens", "DateLastActivity", "DATETIME", existingColumnNames); - AddColumn(db, "AccessTokens", "AppVersion", "TEXT", existingColumnNames); - - }, TransactionMode); - - connection.RunQueries(new[] - { - "update accesstokens set DateLastActivity=DateCreated where DateLastActivity is null", - "update accesstokens set DeviceName='Unknown' where DeviceName is null", - "update accesstokens set AppName='Unknown' where AppName is null", - "update accesstokens set AppVersion='1' where AppVersion is null", - "INSERT INTO Tokens (AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, IsActive, DateCreated, DateLastActivity) SELECT AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, IsActive, DateCreated, DateLastActivity FROM AccessTokens where deviceid not null and devicename not null and appname not null and isactive=1" - }); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error migrating authentication database"); - } - } - - public void Create(AuthenticationInfo info) - { - if (info == null) - { - throw new ArgumentNullException(nameof(info)); - } - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - 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)")) - { - statement.TryBind("@AccessToken", info.AccessToken); - - statement.TryBind("@DeviceId", info.DeviceId); - statement.TryBind("@AppName", info.AppName); - statement.TryBind("@AppVersion", info.AppVersion); - statement.TryBind("@DeviceName", info.DeviceName); - statement.TryBind("@UserId", (info.UserId.Equals(Guid.Empty) ? null : info.UserId.ToString("N"))); - statement.TryBind("@UserName", info.UserName); - statement.TryBind("@IsActive", true); - statement.TryBind("@DateCreated", info.DateCreated.ToDateTimeParamValue()); - statement.TryBind("@DateLastActivity", info.DateLastActivity.ToDateTimeParamValue()); - - statement.MoveNext(); - } - - }, TransactionMode); - } - } - } - - public void Update(AuthenticationInfo info) - { - if (info == null) - { - throw new ArgumentNullException(nameof(info)); - } - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - 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")) - { - statement.TryBind("@Id", info.Id); - - statement.TryBind("@AccessToken", info.AccessToken); - - statement.TryBind("@DeviceId", info.DeviceId); - statement.TryBind("@AppName", info.AppName); - statement.TryBind("@AppVersion", info.AppVersion); - statement.TryBind("@DeviceName", info.DeviceName); - statement.TryBind("@UserId", (info.UserId.Equals(Guid.Empty) ? null : info.UserId.ToString("N"))); - statement.TryBind("@UserName", info.UserName); - statement.TryBind("@DateCreated", info.DateCreated.ToDateTimeParamValue()); - statement.TryBind("@DateLastActivity", info.DateLastActivity.ToDateTimeParamValue()); - - statement.MoveNext(); - } - }, TransactionMode); - } - } - } - - public void Delete(AuthenticationInfo info) - { - if (info == null) - { - throw new ArgumentNullException(nameof(info)); - } - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - using (var statement = db.PrepareStatement("Delete from Tokens where Id=@Id")) - { - statement.TryBind("@Id", info.Id); - - statement.MoveNext(); - } - }, TransactionMode); - } - } - } - - private const string BaseSelectText = "select Tokens.Id, AccessToken, DeviceId, AppName, AppVersion, DeviceName, UserId, UserName, DateCreated, DateLastActivity, Devices.CustomName from Tokens left join Devices on Tokens.DeviceId=Devices.Id"; - - private static void BindAuthenticationQueryParams(AuthenticationInfoQuery query, IStatement statement) - { - if (!string.IsNullOrEmpty(query.AccessToken)) - { - statement.TryBind("@AccessToken", query.AccessToken); - } - - if (!query.UserId.Equals(Guid.Empty)) - { - statement.TryBind("@UserId", query.UserId.ToString("N")); - } - - if (!string.IsNullOrEmpty(query.DeviceId)) - { - statement.TryBind("@DeviceId", query.DeviceId); - } - } - - public QueryResult<AuthenticationInfo> Get(AuthenticationInfoQuery query) - { - if (query == null) - { - throw new ArgumentNullException(nameof(query)); - } - - var commandText = BaseSelectText; - - var whereClauses = new List<string>(); - - if (!string.IsNullOrEmpty(query.AccessToken)) - { - whereClauses.Add("AccessToken=@AccessToken"); - } - - if (!string.IsNullOrEmpty(query.DeviceId)) - { - whereClauses.Add("DeviceId=@DeviceId"); - } - - if (!query.UserId.Equals(Guid.Empty)) - { - whereClauses.Add("UserId=@UserId"); - } - - if (query.HasUser.HasValue) - { - if (query.HasUser.Value) - { - whereClauses.Add("UserId not null"); - } - else - { - whereClauses.Add("UserId is null"); - } - } - - var whereTextWithoutPaging = whereClauses.Count == 0 ? - string.Empty : - " where " + string.Join(" AND ", whereClauses.ToArray()); - - commandText += whereTextWithoutPaging; - - commandText += " ORDER BY DateLastActivity desc"; - - if (query.Limit.HasValue || query.StartIndex.HasValue) - { - var offset = query.StartIndex ?? 0; - - if (query.Limit.HasValue || offset > 0) - { - commandText += " LIMIT " + (query.Limit ?? int.MaxValue).ToString(CultureInfo.InvariantCulture); - } - - if (offset > 0) - { - commandText += " OFFSET " + offset.ToString(CultureInfo.InvariantCulture); - } - } - - var list = new List<AuthenticationInfo>(); - - using (WriteLock.Read()) - { - using (var connection = CreateConnection(true)) - { - return connection.RunInTransaction(db => - { - var result = new QueryResult<AuthenticationInfo>(); - - var statementTexts = new List<string>(); - statementTexts.Add(commandText); - statementTexts.Add("select count (Id) from Tokens" + whereTextWithoutPaging); - - var statements = PrepareAllSafe(db, statementTexts) - .ToList(); - - using (var statement = statements[0]) - { - BindAuthenticationQueryParams(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(Get(row)); - } - - using (var totalCountStatement = statements[1]) - { - BindAuthenticationQueryParams(query, totalCountStatement); - - result.TotalRecordCount = totalCountStatement.ExecuteQuery() - .SelectScalarInt() - .First(); - } - } - - result.Items = list.ToArray(); - return result; - - }, ReadTransactionMode); - } - } - } - - private static AuthenticationInfo Get(IReadOnlyList<IResultSetValue> reader) - { - var info = new AuthenticationInfo - { - Id = reader[0].ToInt64(), - AccessToken = reader[1].ToString() - }; - - if (reader[2].SQLiteType != SQLiteType.Null) - { - info.DeviceId = reader[2].ToString(); - } - - if (reader[3].SQLiteType != SQLiteType.Null) - { - info.AppName = reader[3].ToString(); - } - - if (reader[4].SQLiteType != SQLiteType.Null) - { - info.AppVersion = reader[4].ToString(); - } - - if (reader[5].SQLiteType != SQLiteType.Null) - { - info.DeviceName = reader[5].ToString(); - } - - if (reader[6].SQLiteType != SQLiteType.Null) - { - info.UserId = new Guid(reader[6].ToString()); - } - - if (reader[7].SQLiteType != SQLiteType.Null) - { - info.UserName = reader[7].ToString(); - } - - info.DateCreated = reader[8].ReadDateTime(); - - if (reader[9].SQLiteType != SQLiteType.Null) - { - info.DateLastActivity = reader[9].ReadDateTime(); - } - else - { - info.DateLastActivity = info.DateCreated; - } - - if (reader[10].SQLiteType != SQLiteType.Null) - { - info.DeviceName = reader[10].ToString(); - } - - return info; - } - - public DeviceOptions GetDeviceOptions(string deviceId) - { - using (WriteLock.Read()) - { - using (var connection = CreateConnection(true)) - { - return connection.RunInTransaction(db => - { - using (var statement = PrepareStatementSafe(db, "select CustomName from Devices where Id=@DeviceId")) - { - statement.TryBind("@DeviceId", deviceId); - - var result = new DeviceOptions(); - - foreach (var row in statement.ExecuteQuery()) - { - if (row[0].SQLiteType != SQLiteType.Null) - { - result.CustomName = row[0].ToString(); - } - } - - return result; - } - - }, ReadTransactionMode); - } - } - } - - public void UpdateDeviceOptions(string deviceId, DeviceOptions options) - { - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - - using (WriteLock.Write()) - { - using (var connection = CreateConnection()) - { - connection.RunInTransaction(db => - { - using (var statement = db.PrepareStatement("replace into devices (Id, CustomName, Capabilities) VALUES (@Id, @CustomName, (Select Capabilities from Devices where Id=@Id))")) - { - statement.TryBind("@Id", deviceId); - - if (string.IsNullOrWhiteSpace(options.CustomName)) - { - statement.TryBindNull("@CustomName"); - } - else - { - statement.TryBind("@CustomName", options.CustomName); - } - - statement.MoveNext(); - } - - }, TransactionMode); - } - } - } - } -} diff --git a/Emby.Server.Implementations/Security/EncryptionManager.cs b/Emby.Server.Implementations/Security/EncryptionManager.cs deleted file mode 100644 index fa8872ccc..000000000 --- a/Emby.Server.Implementations/Security/EncryptionManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Text; -using MediaBrowser.Controller.Security; - -namespace Emby.Server.Implementations.Security -{ - public class EncryptionManager : IEncryptionManager - { - /// <summary> - /// Encrypts the string. - /// </summary> - /// <param name="value">The value.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">value</exception> - public string EncryptString(string value) - { - if (value == null) - { - throw new ArgumentNullException(nameof(value)); - } - - return EncryptStringUniversal(value); - } - - /// <summary> - /// Decrypts the string. - /// </summary> - /// <param name="value">The value.</param> - /// <returns>System.String.</returns> - /// <exception cref="ArgumentNullException">value</exception> - public string DecryptString(string value) - { - if (value == null) - { - throw new ArgumentNullException(nameof(value)); - } - - return DecryptStringUniversal(value); - } - - private static string EncryptStringUniversal(string value) - { - // Yes, this isn't good, but ProtectedData in mono is throwing exceptions, so use this for now - - var bytes = Encoding.UTF8.GetBytes(value); - return Convert.ToBase64String(bytes); - } - - private static string DecryptStringUniversal(string value) - { - // Yes, this isn't good, but ProtectedData in mono is throwing exceptions, so use this for now - - var bytes = Convert.FromBase64String(value); - return Encoding.UTF8.GetString(bytes, 0, bytes.Length); - } - } -} diff --git a/Emby.Server.Implementations/Serialization/JsonSerializer.cs b/Emby.Server.Implementations/Serialization/JsonSerializer.cs deleted file mode 100644 index 8ae7fd90c..000000000 --- a/Emby.Server.Implementations/Serialization/JsonSerializer.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; - -namespace Emby.Server.Implementations.Serialization -{ - /// <summary> - /// Provides a wrapper around third party json serialization. - /// </summary> - public class JsonSerializer : IJsonSerializer - { - private readonly IFileSystem _fileSystem; - - public JsonSerializer( - IFileSystem fileSystem) - { - _fileSystem = fileSystem; - Configure(); - } - - /// <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 = _fileSystem.GetFileStream(file, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.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); - } - } - - /// <summary> - /// Configures this instance. - /// </summary> - private void Configure() - { - 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; - } - - private static string SerializeGuid(Guid guid) - { - if (guid.Equals(Guid.Empty)) - { - return null; - } - - return guid.ToString("N"); - } - - /// <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/XmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs index 6400ec16e..059211a0b 100644 --- a/Emby.Server.Implementations/Serialization/XmlSerializer.cs +++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs @@ -1,11 +1,10 @@ using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.IO; using System.Xml; using System.Xml.Serialization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Serialization; -using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Serialization { @@ -14,35 +13,16 @@ namespace Emby.Server.Implementations.Serialization /// </summary> public class MyXmlSerializer : IXmlSerializer { - private readonly IFileSystem _fileSystem; - private readonly ILogger _logger; - - public MyXmlSerializer( - IFileSystem fileSystem, - ILoggerFactory loggerFactory) - { - _fileSystem = fileSystem; - _logger = loggerFactory.CreateLogger("XmlSerializer"); - } - // Need to cache these // http://dotnetcodebox.blogspot.com/2013/01/xmlserializer-class-may-result-in.html - private readonly Dictionary<string, XmlSerializer> _serializers = - new Dictionary<string, XmlSerializer>(); + private static readonly ConcurrentDictionary<string, XmlSerializer> _serializers = + new ConcurrentDictionary<string, XmlSerializer>(); - private XmlSerializer GetSerializer(Type type) - { - var key = type.FullName; - lock (_serializers) - { - if (!_serializers.TryGetValue(key, out var serializer)) - { - serializer = new XmlSerializer(type); - _serializers[key] = serializer; - } - return serializer; - } - } + private static XmlSerializer GetSerializer(Type type) + => _serializers.GetOrAdd( + type.FullName ?? throw new ArgumentException($"Invalid type {type}."), + (_, t) => new XmlSerializer(t), + type); /// <summary> /// Serializes to writer. @@ -61,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)) { @@ -77,10 +57,11 @@ namespace Emby.Server.Implementations.Serialization /// <param name="stream">The stream.</param> public void SerializeToStream(object obj, Stream stream) { - using (var writer = new XmlTextWriter(stream, null)) + using (var writer = new StreamWriter(stream, null, IODefaults.StreamWriterBufferSize, true)) + using (var textWriter = new XmlTextWriter(writer)) { - writer.Formatting = Formatting.Indented; - SerializeToWriter(obj, writer); + textWriter.Formatting = Formatting.Indented; + SerializeToWriter(obj, textWriter); } } @@ -91,8 +72,7 @@ namespace Emby.Server.Implementations.Serialization /// <param name="file">The file.</param> public void SerializeToFile(object obj, string file) { - _logger.LogDebug("Serializing to file {0}", file); - using (var stream = new FileStream(file, FileMode.Create)) + using (var stream = new FileStream(file, FileMode.Create, FileAccess.Write)) { SerializeToStream(obj, stream); } @@ -104,9 +84,8 @@ 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) { - _logger.LogDebug("Deserializing file {0}", file); using (var stream = File.OpenRead(file)) { return DeserializeFromStream(type, stream); @@ -119,9 +98,9 @@ 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)) + using (var stream = new MemoryStream(buffer, 0, buffer.Length, false, true)) { return DeserializeFromStream(type, stream); } diff --git a/Emby.Server.Implementations/ServerApplicationPaths.cs b/Emby.Server.Implementations/ServerApplicationPaths.cs index 05f6469ec..369a2b0d8 100644 --- a/Emby.Server.Implementations/ServerApplicationPaths.cs +++ b/Emby.Server.Implementations/ServerApplicationPaths.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using Emby.Server.Implementations.AppBase; using MediaBrowser.Controller; @@ -6,85 +5,91 @@ using MediaBrowser.Controller; namespace Emby.Server.Implementations { /// <summary> - /// Extends BaseApplicationPaths to add paths that are only applicable on the server + /// Extends BaseApplicationPaths to add paths that are only applicable on the server. /// </summary> public class ServerApplicationPaths : BaseApplicationPaths, IServerApplicationPaths { /// <summary> - /// Initializes a new instance of the <see cref="BaseApplicationPaths" /> class. + /// Initializes a new instance of the <see cref="ServerApplicationPaths" /> class. /// </summary> + /// <param name="programDataPath">The path for Jellyfin's data.</param> + /// <param name="logDirectoryPath">The path for Jellyfin's logging directory.</param> + /// <param name="configurationDirectoryPath">The path for Jellyfin's configuration directory.</param> + /// <param name="cacheDirectoryPath">The path for Jellyfin's cache directory.</param> + /// <param name="webDirectoryPath">The path for Jellyfin's web UI.</param> public ServerApplicationPaths( string programDataPath, string logDirectoryPath, string configurationDirectoryPath, - string cacheDirectoryPath) - : base(programDataPath, + string cacheDirectoryPath, + string webDirectoryPath) + : base( + programDataPath, logDirectoryPath, configurationDirectoryPath, - cacheDirectoryPath) + 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; } - public string ApplicationResourcesPath { get; } = AppContext.BaseDirectory; - /// <summary> - /// Gets the path to the base root media directory + /// 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"); - - /// <summary> - /// Gets the path to localization data. - /// </summary> - /// <value>The localization path.</value> - public string LocalizationPath => Path.Combine(ProgramDataPath, "localization"); + public string DefaultUserViewsPath { get; } /// <summary> - /// Gets the path to the People directory + /// Gets the path to the People directory. /// </summary> /// <value>The people path.</value> public string PeoplePath => Path.Combine(InternalMetadataPath, "People"); + /// <inheritdoc /> public string ArtistsPath => Path.Combine(InternalMetadataPath, "artists"); /// <summary> - /// Gets the path to the Genre directory + /// Gets the path to the Genre directory. /// </summary> /// <value>The genre path.</value> public string GenrePath => Path.Combine(InternalMetadataPath, "Genre"); /// <summary> - /// Gets the path to the Genre directory + /// Gets the path to the Genre directory. /// </summary> /// <value>The genre path.</value> public string MusicGenrePath => Path.Combine(InternalMetadataPath, "MusicGenre"); /// <summary> - /// Gets the path to the Studio directory + /// Gets the path to the Studio directory. /// </summary> /// <value>The studio path.</value> public string StudioPath => Path.Combine(InternalMetadataPath, "Studio"); /// <summary> - /// Gets the path to the Year directory + /// Gets the path to the Year directory. /// </summary> /// <value>The year path.</value> public string YearPath => Path.Combine(InternalMetadataPath, "Year"); /// <summary> - /// Gets the path to the General IBN directory + /// Gets the path to the General IBN directory. /// </summary> /// <value>The general path.</value> public string GeneralPath => Path.Combine(InternalMetadataPath, "general"); /// <summary> - /// Gets the path to the Ratings IBN directory + /// Gets the path to the Ratings IBN directory. /// </summary> /// <value>The ratings path.</value> public string RatingsPath => Path.Combine(InternalMetadataPath, "ratings"); @@ -96,54 +101,18 @@ namespace Emby.Server.Implementations public string MediaInfoImagesPath => Path.Combine(InternalMetadataPath, "mediainfo"); /// <summary> - /// Gets the path to the user configuration directory + /// Gets the path to the user configuration directory. /// </summary> /// <value>The user configuration directory path.</value> public string UserConfigurationDirectoryPath => Path.Combine(ConfigurationDirectoryPath, "users"); - private string _defaultTranscodingTempPath; - public string DefaultTranscodingTempPath => _defaultTranscodingTempPath ?? (_defaultTranscodingTempPath = Path.Combine(ProgramDataPath, "transcoding-temp")); - - private string _transcodingTempPath; - public string TranscodingTempPath - { - get => _transcodingTempPath ?? (_transcodingTempPath = DefaultTranscodingTempPath); - set => _transcodingTempPath = value; - } - - public string GetTranscodingTempPath() - { - var path = TranscodingTempPath; - - if (!string.Equals(path, DefaultTranscodingTempPath, StringComparison.OrdinalIgnoreCase)) - { - try - { - Directory.CreateDirectory(path); - - var testPath = Path.Combine(path, Guid.NewGuid().ToString()); - Directory.CreateDirectory(testPath); - Directory.Delete(testPath); - - return path; - } - catch - { - } - } - - path = DefaultTranscodingTempPath; - Directory.CreateDirectory(path); - return path; - } + /// <inheritdoc/> + public string DefaultInternalMetadataPath { get; } - private string _internalMetadataPath; - public string InternalMetadataPath - { - get => _internalMetadataPath ?? (_internalMetadataPath = Path.Combine(DataPath, "metadata")); - set => _internalMetadataPath = value; - } + /// <inheritdoc /> + public string InternalMetadataPath { get; set; } - public string VirtualInternalMetadataPath { get; } = "%MetadataPath%"; + /// <inheritdoc /> + 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 296da2f7a..000000000 --- a/Emby.Server.Implementations/Services/HttpResult.cs +++ /dev/null @@ -1,61 +0,0 @@ -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 object Response { get; set; } - - public HttpResult(object response, string contentType, HttpStatusCode statusCode) - { - this.Headers = new Dictionary<string, string>(); - - this.Response = response; - this.ContentType = contentType; - this.StatusCode = statusCode; - } - - 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 == null ? null : RequestContext.Response; - - if (this.Response is byte[] bytesResponse) - { - var contentLength = bytesResponse.Length; - - if (response != null) - { - response.SetContentLength(contentLength); - } - - if (contentLength > 0) - { - await responseStream.WriteAsync(bytesResponse, 0, contentLength).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 2563cac99..000000000 --- a/Emby.Server.Implementations/Services/RequestHelper.cs +++ /dev/null @@ -1,52 +0,0 @@ -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 == null - ? null - : 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 dc9975347..000000000 --- a/Emby.Server.Implementations/Services/ResponseHelper.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using System.Collections.Generic; -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; - -namespace Emby.Server.Implementations.Services -{ - public static class ResponseHelper - { - public static Task WriteToResponse(IResponse response, IRequest request, object result, CancellationToken cancellationToken) - { - if (result == null) - { - if (response.StatusCode == (int)HttpStatusCode.OK) - { - response.StatusCode = (int)HttpStatusCode.NoContent; - } - - response.SetContentLength(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; - response.StatusDescription = httpResult.StatusCode.ToString(); - //if (string.IsNullOrEmpty(httpResult.ContentType)) - //{ - // httpResult.ContentType = defaultContentType; - //} - //response.ContentType = httpResult.ContentType; - } - - var responseOptions = result as IHasHeaders; - if (responseOptions != null) - { - foreach (var responseHeaders in responseOptions.Headers) - { - if (string.Equals(responseHeaders.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) - { - response.SetContentLength(long.Parse(responseHeaders.Value)); - continue; - } - - response.AddHeader(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"; - } - - var asyncStreamWriter = result as IAsyncStreamWriter; - if (asyncStreamWriter != null) - { - return asyncStreamWriter.WriteToAsync(response.OutputStream, cancellationToken); - } - - var streamWriter = result as IStreamWriter; - if (streamWriter != null) - { - streamWriter.WriteTo(response.OutputStream); - return Task.CompletedTask; - } - - var fileWriter = result as FileWriter; - if (fileWriter != null) - { - return fileWriter.WriteToAsync(response, cancellationToken); - } - - var stream = result as Stream; - if (stream != null) - { - return CopyStream(stream, response.OutputStream); - } - - var bytes = result as byte[]; - if (bytes != null) - { - response.ContentType = "application/octet-stream"; - response.SetContentLength(bytes.Length); - - if (bytes.Length > 0) - { - return response.OutputStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken); - } - return Task.CompletedTask; - } - - var responseText = result as string; - if (responseText != null) - { - bytes = Encoding.UTF8.GetBytes(responseText); - response.SetContentLength(bytes.Length); - if (bytes.Length > 0) - { - return response.OutputStream.WriteAsync(bytes, 0, bytes.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, IResponse 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.SetContentLength(contentLength); - - if (contentLength > 0) - { - await ms.CopyToAsync(response.OutputStream).ConfigureAwait(false); - } - } - - //serializer(result, outputStream); - } - } -} diff --git a/Emby.Server.Implementations/Services/ServiceController.cs b/Emby.Server.Implementations/Services/ServiceController.cs deleted file mode 100644 index 5796956d8..000000000 --- a/Emby.Server.Implementations/Services/ServiceController.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading.Tasks; -using Emby.Server.Implementations.HttpServer; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.Services -{ - public delegate Task<object> InstanceExecFn(IRequest requestContext, object intance, object request); - public delegate object ActionInvokerFn(object intance, object request); - public delegate void VoidActionInvokerFn(object intance, object request); - - public class ServiceController - { - public static ServiceController Instance; - - public ServiceController() - { - Instance = this; - } - - public void Init(HttpListenerHost appHost, Type[] serviceTypes) - { - foreach (var serviceType in serviceTypes) - { - RegisterService(appHost, serviceType); - } - } - - public void RegisterService(HttpListenerHost appHost, Type serviceType) - { - 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 static Type FirstGenericType(Type type) - { - while (type != null) - { - if (type.GetTypeInfo().IsGenericType) - return type; - - type = type.GetTypeInfo().BaseType; - } - return null; - } - - 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.StartsWith("/")) - throw new ArgumentException(string.Format("Route '{0}' on '{1}' must start with a '/'", restPath.Path, restPath.RequestType.GetMethodName())); - if (restPath.Path.IndexOfAny(InvalidRouteChars) != -1) - throw new ArgumentException(string.Format("Route '{0}' on '{1}' contains invalid chars. ", restPath.Path, restPath.RequestType.GetMethodName())); - - if (!RestPathMap.TryGetValue(restPath.FirstMatchHashKey, out List<RestPath> pathsAtFirstMatch)) - { - pathsAtFirstMatch = new List<RestPath>(); - RestPathMap[restPath.FirstMatchHashKey] = pathsAtFirstMatch; - } - pathsAtFirstMatch.Add(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 appHost, object requestDto, IRequest req) - { - req.Dto = requestDto; - var requestType = requestDto.GetType(); - req.OperationName = requestType.Name; - - var serviceType = appHost.GetServiceTypeByRequest(requestType); - - var service = appHost.CreateInstance(serviceType); - - //var service = typeFactory.CreateInstance(serviceType); - - var serviceRequiresContext = service as IRequiresRequest; - if (serviceRequiresContext != null) - { - serviceRequiresContext.Request = req; - } - - if (req.Dto == null) // Don't override existing batched DTO[] - req.Dto = requestDto; - - //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 79f5c59e6..000000000 --- a/Emby.Server.Implementations/Services/ServiceExec.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System; -using System.Collections.Generic; -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.IsClosed) - { - Task.FromResult<object>(null); - } - } - } - - var response = actionContext.ServiceAction(instance, requestDto); - - var taskResponse = response as Task; - if (taskResponse != null) - { - return GetTaskResult(taskResponse); - } - - return Task.FromResult(response); - } - - var expectedMethodName = actionName.Substring(0, 1) + actionName.Substring(1).ToLowerInvariant(); - throw new NotImplementedException(string.Format("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 - { - var taskObject = task as Task<object>; - if (taskObject != null) - { - 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()) - }; - - try - { - actionCtx.ServiceAction = CreateExecFn(serviceType, requestType, mi); - } - catch - { - //Potential problems with MONO, using reflection for fallback - actionCtx.ServiceAction = (service, request) => - mi.Invoke(service, new[] { request }); - } - - var reqFilters = new List<IHasRequestFilter>(); - - foreach (var attr in mi.GetCustomAttributes(true)) - { - var hasReqFilter = attr as IHasRequestFilter; - - if (hasReqFilter != null) - 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 7e836e22c..000000000 --- a/Emby.Server.Implementations/Services/ServiceHandler.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.HttpServer; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.Services -{ - public class ServiceHandler - { - 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(requestType, httpReq.InputStream); - } - } - return Task.FromResult(host.CreateInstance(requestType)); - } - - public static RestPath FindMatchingRestPath(string httpMethod, string pathInfo, out string contentType) - { - pathInfo = GetSanitizedPathInfo(pathInfo, out contentType); - - return ServiceController.Instance.GetRestPathForRequest(httpMethod, pathInfo); - } - - public static string GetSanitizedPathInfo(string pathInfo, out string contentType) - { - contentType = null; - var pos = pathInfo.LastIndexOf('.'); - if (pos >= 0) - { - var format = pathInfo.Substring(pos + 1); - contentType = GetFormatContentType(format); - if (contentType != null) - { - pathInfo = pathInfo.Substring(0, pos); - } - } - return pathInfo; - } - - private static string GetFormatContentType(string format) - { - //built-in formats - if (format == "json") - return "application/json"; - if (format == "xml") - return "application/xml"; - - return null; - } - - public RestPath GetRestPath(string httpMethod, string pathInfo) - { - if (this.RestPath == null) - { - this.RestPath = FindMatchingRestPath(httpMethod, pathInfo, out string contentType); - - if (contentType != null) - ResponseContentType = contentType; - } - return this.RestPath; - } - - public RestPath RestPath { get; set; } - - // Set from SSHHF.GetHandlerForPathInfo() - public string ResponseContentType { get; set; } - - public async Task ProcessRequestAsync(HttpListenerHost appHost, IRequest httpReq, IResponse httpRes, ILogger logger, string operationName, CancellationToken cancellationToken) - { - var restPath = GetRestPath(httpReq.Verb, httpReq.PathInfo); - if (restPath == null) - { - throw new NotSupportedException("No RestPath found for: " + httpReq.Verb + " " + httpReq.PathInfo); - } - - SetRoute(httpReq, restPath); - - if (ResponseContentType != null) - httpReq.ResponseContentType = ResponseContentType; - - var request = httpReq.Dto = await CreateRequest(appHost, httpReq, restPath, logger).ConfigureAwait(false); - - appHost.ApplyRequestFilters(httpReq, httpRes, request); - - var response = await appHost.ServiceController.Execute(appHost, request, httpReq).ConfigureAwait(false); - - // Apply response filters - foreach (var responseFilter in appHost.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, ILogger logger) - { - var requestType = restPath.RequestType; - - if (RequireqRequestStream(requestType)) - { - // Used by IRequiresRequestStream - var requestParams = await GetRequestParams(httpReq).ConfigureAwait(false); - var request = ServiceHandler.CreateRequest(httpReq, restPath, requestParams, host.CreateInstance(requestType)); - - var rawReq = (IRequiresRequestStream)request; - rawReq.RequestStream = httpReq.InputStream; - return rawReq; - } - else - { - var requestParams = await GetFlattenedRequestParams(httpReq).ConfigureAwait(false); - - 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 string contentType) - : httpReq.PathInfo; - - return restPath.CreateRequest(pathInfo, requestParams, requestDto); - } - - /// <summary> - /// Duplicate Params are given a unique key by appending a #1 suffix - /// </summary> - private static async Task<Dictionary<string, string>> GetRequestParams(IRequest request) - { - var map = new Dictionary<string, string>(); - - foreach (var name in request.QueryString.Keys) - { - if (name == null) continue; //thank you ASP.NET - - var values = request.QueryString.GetValues(name); - if (values.Count == 1) - { - map[name] = values[0]; - } - else - { - for (var i = 0; i < values.Count; i++) - { - map[name + (i == 0 ? "" : "#" + i)] = values[i]; - } - } - } - - if ((IsMethod(request.Verb, "POST") || IsMethod(request.Verb, "PUT"))) - { - var formData = await request.GetFormData().ConfigureAwait(false); - if (formData != null) - { - foreach (var name in formData.Keys) - { - if (name == null) continue; //thank you ASP.NET - - var values = formData.GetValues(name); - if (values.Count == 1) - { - map[name] = values[0]; - } - else - { - for (var i = 0; i < values.Count; i++) - { - map[name + (i == 0 ? "" : "#" + i)] = values[i]; - } - } - } - } - } - - return map; - } - - private static bool IsMethod(string method, string expected) - { - return string.Equals(method, expected, StringComparison.OrdinalIgnoreCase); - } - - /// <summary> - /// Duplicate params have their values joined together in a comma-delimited string - /// </summary> - private static async Task<Dictionary<string, string>> GetFlattenedRequestParams(IRequest request) - { - var map = new Dictionary<string, string>(); - - foreach (var name in request.QueryString.Keys) - { - if (name == null) continue; //thank you ASP.NET - map[name] = request.QueryString[name]; - } - - if ((IsMethod(request.Verb, "POST") || IsMethod(request.Verb, "PUT"))) - { - var formData = await request.GetFormData().ConfigureAwait(false); - if (formData != null) - { - foreach (var name in formData.Keys) - { - if (name == null) continue; //thank you ASP.NET - map[name] = formData[name]; - } - } - } - - return map; - } - - private static void SetRoute(IRequest req, RestPath route) - { - req.Items["__route"] = route; - } - - private static RestPath GetRoute(IRequest req) - { - req.Items.TryGetValue("__route", out var route); - return route as RestPath; - } - } - -} diff --git a/Emby.Server.Implementations/Services/ServiceMethod.cs b/Emby.Server.Implementations/Services/ServiceMethod.cs deleted file mode 100644 index 5018bf4a2..000000000 --- a/Emby.Server.Implementations/Services/ServiceMethod.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 f575baca3..000000000 --- a/Emby.Server.Implementations/Services/ServicePath.cs +++ /dev/null @@ -1,564 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; - -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 = "{"; - - 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; - - 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> - /// 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 int Priority { get; set; } //passed back to RouteAttribute - - public IEnumerable<string> PathVariables => this.variablesNames.Where(e => !string.IsNullOrWhiteSpace(e)); - - 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); - - var subParts = part.Split(ComponentSeperator); - if (subParts.Length == 1) continue; - - 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 (StringContains(component, VariablePrefix) - && component.IndexOf(ComponentSeperator) != -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)) - { - 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.Count(x => x); - this.IsWildCardPath = this.wildcardCount > 0; - - this.FirstMatchHashKey = !this.IsWildCardPath - ? this.PathComponentsCount + PathSeperator + firstLiteralMatch - : WildCardChar + PathSeperator + firstLiteralMatch; - - this.typeDeserializer = new StringMapTypeDeserializer(createInstanceFn, getParseFn, this.RequestType); - RegisterCaseInsenstivePropertyNameMappings(); - } - - private void RegisterCaseInsenstivePropertyNameMappings() - { - foreach (var propertyInfo in GetSerializableProperties(RequestType)) - { - var propertyName = propertyInfo.Name; - propertyNamesMap.Add(propertyName.ToLowerInvariant(), propertyName); - } - } - - internal static string[] IgnoreAttributesNamed = new[] { - "IgnoreDataMemberAttribute", - "JsonIgnoreAttribute" - }; - - - private static Type excludeType = typeof(Stream); - - internal static List<PropertyInfo> GetSerializableProperties(Type type) - { - var list = new List<PropertyInfo>(); - var props = GetPublicProperties(type); - - foreach (var prop in props) - { - if (prop.GetMethod == null) - { - continue; - } - - if (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) - { - list.Add(prop); - } - } - - // else return those properties that are not decorated with IgnoreDataMember - return list; - } - - private static List<PropertyInfo> GetPublicProperties(Type type) - { - if (type.GetTypeInfo().IsInterface) - { - var propertyInfos = new List<PropertyInfo>(); - - var considered = new List<Type>(); - var queue = new Queue<Type>(); - considered.Add(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 typeProperties = GetTypesPublicProperties(subType); - - var newPropertyInfos = typeProperties - .Where(x => !propertyInfos.Contains(x)); - - propertyInfos.InsertRange(0, newPropertyInfos); - } - - return propertyInfos; - } - - var list = new List<PropertyInfo>(); - - foreach (var t in GetTypesPublicProperties(type)) - { - if (t.GetIndexParameters().Length == 0) - { - list.Add(t); - } - } - return list; - } - - private static PropertyInfo[] GetTypesPublicProperties(Type subType) - { - var pis = new List<PropertyInfo>(); - foreach (var pi in subType.GetRuntimeProperties()) - { - var mi = pi.GetMethod ?? pi.SetMethod; - if (mi != null && mi.IsStatic) continue; - pis.Add(pi); - } - return pis.ToArray(); - } - - /// <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 Dictionary<string, string> propertyNamesMap = new Dictionary<string, string>(); - - public int MatchScore(string httpMethod, string[] withPathInfoParts) - { - var isMatch = IsMatch(httpMethod, withPathInfoParts, out var wildcardMatchCount); - if (!isMatch) - { - return -1; - } - - var score = 0; - - //Routes with least wildcard matches get the highest score - score += Math.Max((100 - wildcardMatchCount), 1) * 1000; - - //Routes with less variable (and more literal) matches - score += 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; - } - - private bool StringContains(string str1, string str2) - { - return str1.IndexOf(str2, StringComparison.OrdinalIgnoreCase) != -1; - } - - /// <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 && !LiteralsEqual(withPathInfoParts[pathIx], this.literalsToMatch[i + 1])) - { - 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 || !LiteralsEqual(withPathInfoParts[pathIx], literalToMatch)) - { - return false; - } - pathIx++; - } - } - - return pathIx == withPathInfoParts.Length; - } - - private static bool LiteralsEqual(string str1, string str2) - { - // Most cases - if (string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - // Handle turkish i - str1 = str1.ToUpperInvariant(); - str2 = str2.ToUpperInvariant(); - - // Invariant IgnoreCase would probably be better but it's not available in PCL - return string.Equals(str1, str2, StringComparison.CurrentCultureIgnoreCase); - } - - 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( - "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.TryGetValue(variableName.ToLowerInvariant(), out var propertyNameOnRequest)) - { - 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 + 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(); - sb.Append(value); - pathIx++; - while (!string.Equals(requestComponents[pathIx], stopLiteral, StringComparison.OrdinalIgnoreCase)) - { - sb.Append(PathSeperatorChar + requestComponents[pathIx++]); - } - value = sb.ToString(); - } - else - { - value = null; - } - } - } - else - { - // Variable consumes single path item - pathIx++; - } - - requestKeyValuesMap[propertyNameOnRequest] = 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 d13935fba..000000000 --- a/Emby.Server.Implementations/Services/StringMapTypeDeserializer.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; - -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) - { - PropertySetFn = propertySetFn; - PropertyParseStringFn = propertyParseStringFn; - } - - public Action<object, object> PropertySetFn; - public Func<string, object> PropertyParseStringFn; - public Type PropertyType; - } - - 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(type, propertyInfo); - var propertyType = propertyInfo.PropertyType; - var propertyParseStringFn = GetParseFn(propertyType); - var propertySerializer = new PropertySerializerEntry(propertySetFn, propertyParseStringFn) { PropertyType = propertyType }; - - propertySetterMap[propertyInfo.Name] = propertySerializer; - } - } - - public object PopulateFromMap(object instance, IDictionary<string, string> keyValuePairs) - { - string propertyName = null; - string propertyTextValue = null; - PropertySerializerEntry propertySerializerEntry = null; - - if (instance == null) - instance = _CreateInstanceFn(type); - - foreach (var pair in keyValuePairs) - { - propertyName = pair.Key; - propertyTextValue = pair.Value; - - if (string.IsNullOrEmpty(propertyTextValue)) - { - continue; - } - - if (!propertySetterMap.TryGetValue(propertyName, out propertySerializerEntry)) - { - if (propertyName == "v") - { - continue; - } - - continue; - } - - if (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 = LeftPart(propertyTextValue, ','); - } - - var value = propertySerializerEntry.PropertyParseStringFn(propertyTextValue); - if (value == null) - { - continue; - } - propertySerializerEntry.PropertySetFn(instance, value); - } - - return instance; - } - - public static string LeftPart(string strVal, char needle) - { - if (strVal == null) return null; - var pos = strVal.IndexOf(needle); - return pos == -1 - ? strVal - : strVal.Substring(0, pos); - } - } - - internal class TypeAccessor - { - public static Action<object, object> GetSetPropertyMethod(Type type, 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/SwaggerService.cs b/Emby.Server.Implementations/Services/SwaggerService.cs deleted file mode 100644 index 3e6970eef..000000000 --- a/Emby.Server.Implementations/Services/SwaggerService.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.Services -{ - [Route("/swagger", "GET", Summary = "Gets the swagger specifications")] - [Route("/swagger.json", "GET", Summary = "Gets the swagger specifications")] - public class GetSwaggerSpec : IReturn<SwaggerSpec> - { - } - - public class SwaggerSpec - { - public string swagger { get; set; } - public string[] schemes { get; set; } - public SwaggerInfo info { get; set; } - public string host { get; set; } - public string basePath { get; set; } - public SwaggerTag[] tags { get; set; } - public IDictionary<string, Dictionary<string, SwaggerMethod>> paths { get; set; } - public Dictionary<string, SwaggerDefinition> definitions { get; set; } - public SwaggerComponents components { get; set; } - } - - public class SwaggerComponents - { - public Dictionary<string, SwaggerSecurityScheme> securitySchemes { get; set; } - } - - public class SwaggerSecurityScheme - { - public string name { get; set; } - public string type { get; set; } - public string @in { get; set; } - } - - public class SwaggerInfo - { - public string description { get; set; } - public string version { get; set; } - public string title { get; set; } - public string termsOfService { get; set; } - - public SwaggerConcactInfo contact { get; set; } - } - - public class SwaggerConcactInfo - { - public string email { get; set; } - public string name { get; set; } - public string url { get; set; } - } - - public class SwaggerTag - { - public string description { get; set; } - public string name { get; set; } - } - - public class SwaggerMethod - { - public string summary { get; set; } - public string description { get; set; } - public string[] tags { get; set; } - public string operationId { get; set; } - public string[] consumes { get; set; } - public string[] produces { get; set; } - public SwaggerParam[] parameters { get; set; } - public Dictionary<string, SwaggerResponse> responses { get; set; } - public Dictionary<string, string[]>[] security { get; set; } - } - - public class SwaggerParam - { - public string @in { get; set; } - public string name { get; set; } - public string description { get; set; } - public bool required { get; set; } - public string type { get; set; } - public string collectionFormat { get; set; } - } - - public class SwaggerResponse - { - public string description { get; set; } - - // ex. "$ref":"#/definitions/Pet" - public Dictionary<string, string> schema { get; set; } - } - - public class SwaggerDefinition - { - public string type { get; set; } - public Dictionary<string, SwaggerProperty> properties { get; set; } - } - - public class SwaggerProperty - { - public string type { get; set; } - public string format { get; set; } - public string description { get; set; } - public string[] @enum { get; set; } - public string @default { get; set; } - } - - public class SwaggerService : IService, IRequiresRequest - { - private SwaggerSpec _spec; - - public IRequest Request { get; set; } - - public object Get(GetSwaggerSpec request) - { - return _spec ?? (_spec = GetSpec()); - } - - private SwaggerSpec GetSpec() - { - string host = null; - Uri uri; - if (Uri.TryCreate(Request.RawUrl, UriKind.Absolute, out uri)) - { - host = uri.Host; - } - - var securitySchemes = new Dictionary<string, SwaggerSecurityScheme>(); - - securitySchemes["api_key"] = new SwaggerSecurityScheme - { - name = "api_key", - type = "apiKey", - @in = "query" - }; - - var spec = new SwaggerSpec - { - schemes = new[] { "http" }, - tags = GetTags(), - swagger = "2.0", - info = new SwaggerInfo - { - title = "Jellyfin Server API", - version = "1.0.0", - description = "Explore the Jellyfin Server API", - contact = new SwaggerConcactInfo - { - name = "Jellyfin Community", - url = "https://jellyfin.readthedocs.io/en/latest/user-docs/getting-help/" - } - }, - paths = GetPaths(), - definitions = GetDefinitions(), - basePath = "/jellyfin", - host = host, - - components = new SwaggerComponents - { - securitySchemes = securitySchemes - } - }; - - return spec; - } - - - private SwaggerTag[] GetTags() - { - return new SwaggerTag[] { }; - } - - private Dictionary<string, SwaggerDefinition> GetDefinitions() - { - return new Dictionary<string, SwaggerDefinition>(); - } - - private IDictionary<string, Dictionary<string, SwaggerMethod>> GetPaths() - { - var paths = new SortedDictionary<string, Dictionary<string, SwaggerMethod>>(); - - var all = ServiceController.Instance.RestPathMap.OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase).ToList(); - - foreach (var current in all) - { - foreach (var info in current.Value) - { - if (info.IsHidden) - { - continue; - } - - if (info.Path.StartsWith("/mediabrowser", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - if (info.Path.StartsWith("/jellyfin", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - paths[info.Path] = GetPathInfo(info); - } - } - - return paths; - } - - private Dictionary<string, SwaggerMethod> GetPathInfo(RestPath info) - { - var result = new Dictionary<string, SwaggerMethod>(); - - foreach (var verb in info.Verbs) - { - var responses = new Dictionary<string, SwaggerResponse> - { - { "200", new SwaggerResponse { description = "OK" } } - }; - - var apiKeySecurity = new Dictionary<string, string[]> - { - { "api_key", Array.Empty<string>() } - }; - - result[verb.ToLowerInvariant()] = new SwaggerMethod - { - summary = info.Summary, - description = info.Description, - produces = new[] { "application/json" }, - consumes = new[] { "application/json" }, - operationId = info.RequestType.Name, - tags = Array.Empty<string>(), - - parameters = Array.Empty<SwaggerParam>(), - - responses = responses, - - security = new [] { apiKeySecurity } - }; - } - - return result; - } - } -} diff --git a/Emby.Server.Implementations/Services/UrlExtensions.cs b/Emby.Server.Implementations/Services/UrlExtensions.cs deleted file mode 100644 index 8899fbfa3..000000000 --- a/Emby.Server.Implementations/Services/UrlExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; - -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 - ? LeftPart(type.FullName, "[[") //Generic Fullname - .Replace(type.Namespace + ".", "") //Trim Namespaces - .Replace("+", ".") //Convert nested into normal type - : type.Name; - - return type.IsGenericParameter ? "'" + typeName : typeName; - } - - private static string LeftPart(string strVal, string needle) - { - if (strVal == null) return null; - var pos = strVal.IndexOf(needle, StringComparison.OrdinalIgnoreCase); - return pos == -1 - ? strVal - : strVal.Substring(0, pos); - } - } -} diff --git a/Emby.Server.Implementations/Session/HttpSessionController.cs b/Emby.Server.Implementations/Session/HttpSessionController.cs deleted file mode 100644 index 9281f82b3..000000000 --- a/Emby.Server.Implementations/Session/HttpSessionController.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Session; - -namespace Emby.Server.Implementations.Session -{ - public class HttpSessionController : ISessionController - { - private readonly IHttpClient _httpClient; - private readonly IJsonSerializer _json; - private readonly ISessionManager _sessionManager; - - public SessionInfo Session { get; private set; } - - private readonly string _postUrl; - - public HttpSessionController(IHttpClient httpClient, - IJsonSerializer json, - SessionInfo session, - string postUrl, ISessionManager sessionManager) - { - _httpClient = httpClient; - _json = json; - Session = session; - _postUrl = postUrl; - _sessionManager = sessionManager; - } - - private string PostUrl => string.Format("http://{0}{1}", Session.RemoteEndPoint, _postUrl); - - public bool IsSessionActive => (DateTime.UtcNow - Session.LastActivityDate).TotalMinutes <= 5; - - public bool SupportsMediaControl => true; - - private Task SendMessage(string name, string messageId, CancellationToken cancellationToken) - { - return SendMessage(name, messageId, new Dictionary<string, string>(), cancellationToken); - } - - private Task SendMessage(string name, string messageId, Dictionary<string, string> args, CancellationToken cancellationToken) - { - args["messageId"] = messageId; - var url = PostUrl + "/" + name + ToQueryString(args); - - return SendRequest(new HttpRequestOptions - { - Url = url, - CancellationToken = cancellationToken, - BufferContent = false - }); - } - - private Task SendPlayCommand(PlayRequest command, string messageId, CancellationToken cancellationToken) - { - var dict = new Dictionary<string, string>(); - - dict["ItemIds"] = string.Join(",", command.ItemIds.Select(i => i.ToString("N")).ToArray()); - - if (command.StartPositionTicks.HasValue) - { - dict["StartPositionTicks"] = command.StartPositionTicks.Value.ToString(CultureInfo.InvariantCulture); - } - if (command.AudioStreamIndex.HasValue) - { - dict["AudioStreamIndex"] = command.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture); - } - if (command.SubtitleStreamIndex.HasValue) - { - dict["SubtitleStreamIndex"] = command.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture); - } - if (command.StartIndex.HasValue) - { - dict["StartIndex"] = command.StartIndex.Value.ToString(CultureInfo.InvariantCulture); - } - if (!string.IsNullOrEmpty(command.MediaSourceId)) - { - dict["MediaSourceId"] = command.MediaSourceId; - } - - return SendMessage(command.PlayCommand.ToString(), messageId, dict, cancellationToken); - } - - private Task SendPlaystateCommand(PlaystateRequest command, string messageId, CancellationToken cancellationToken) - { - var args = new Dictionary<string, string>(); - - if (command.Command == PlaystateCommand.Seek) - { - if (!command.SeekPositionTicks.HasValue) - { - throw new ArgumentException("SeekPositionTicks cannot be null"); - } - - args["SeekPositionTicks"] = command.SeekPositionTicks.Value.ToString(CultureInfo.InvariantCulture); - } - - return SendMessage(command.Command.ToString(), messageId, args, cancellationToken); - } - - private string[] _supportedMessages = new string[] { }; - public Task SendMessage<T>(string name, string messageId, T data, ISessionController[] allControllers, CancellationToken cancellationToken) - { - if (!IsSessionActive) - { - return Task.CompletedTask; - } - - if (string.Equals(name, "Play", StringComparison.OrdinalIgnoreCase)) - { - return SendPlayCommand(data as PlayRequest, messageId, cancellationToken); - } - if (string.Equals(name, "PlayState", StringComparison.OrdinalIgnoreCase)) - { - return SendPlaystateCommand(data as PlaystateRequest, messageId, cancellationToken); - } - if (string.Equals(name, "GeneralCommand", StringComparison.OrdinalIgnoreCase)) - { - var command = data as GeneralCommand; - return SendMessage(command.Name, messageId, command.Arguments, cancellationToken); - } - - if (!_supportedMessages.Contains(name, StringComparer.OrdinalIgnoreCase)) - { - return Task.CompletedTask; - } - - var url = PostUrl + "/" + name; - - url += "?messageId=" + messageId; - - var options = new HttpRequestOptions - { - Url = url, - CancellationToken = cancellationToken, - BufferContent = false - }; - - if (data != null) - { - if (typeof(T) == typeof(string)) - { - var str = data as string; - if (!string.IsNullOrEmpty(str)) - { - options.RequestContent = str; - options.RequestContentType = "application/json"; - } - } - else - { - options.RequestContent = _json.SerializeToString(data); - options.RequestContentType = "application/json"; - } - } - - return SendRequest(options); - } - - private async Task SendRequest(HttpRequestOptions options) - { - using (var response = await _httpClient.Post(options).ConfigureAwait(false)) - { - - } - } - - private static string ToQueryString(Dictionary<string, string> nvc) - { - var array = (from item in nvc - select string.Format("{0}={1}", WebUtility.UrlEncode(item.Key), WebUtility.UrlEncode(item.Value))) - .ToArray(); - - var args = string.Join("&", array); - - if (string.IsNullOrEmpty(args)) - { - return args; - } - - return "?" + args; - } - } -} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index fa0ab62d3..341d2c8e6 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -5,125 +9,139 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Entities.Security; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; +using Jellyfin.Extensions; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Events.Session; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Devices; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Events; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Session; +using MediaBrowser.Model.SyncPlay; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Episode = MediaBrowser.Controller.Entities.TV.Episode; namespace Emby.Server.Implementations.Session { /// <summary> - /// Class SessionManager + /// Class SessionManager. /// </summary> public class SessionManager : ISessionManager, IDisposable { - /// <summary> - /// The _user data repository - /// </summary> private readonly IUserDataManager _userDataManager; - - /// <summary> - /// The _logger - /// </summary> - private readonly ILogger _logger; - + private readonly ILogger<SessionManager> _logger; + private readonly IEventManager _eventManager; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IMusicManager _musicManager; private readonly IDtoService _dtoService; private readonly IImageProcessor _imageProcessor; private readonly IMediaSourceManager _mediaSourceManager; - - private readonly IHttpClient _httpClient; - private readonly IJsonSerializer _jsonSerializer; private readonly IServerApplicationHost _appHost; - - private readonly IAuthenticationRepository _authRepo; private readonly IDeviceManager _deviceManager; /// <summary> - /// The _active connections + /// 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); - public event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationFailed; - - public event EventHandler<GenericEventArgs<AuthenticationResult>> AuthenticationSucceeded; - - /// <summary> - /// Occurs when [playback start]. - /// </summary> - public event EventHandler<PlaybackProgressEventArgs> PlaybackStart; - /// <summary> - /// Occurs when [playback progress]. - /// </summary> - public event EventHandler<PlaybackProgressEventArgs> PlaybackProgress; - /// <summary> - /// Occurs when [playback stopped]. - /// </summary> - public event EventHandler<PlaybackStopEventArgs> PlaybackStopped; + private Timer _idleTimer; - public event EventHandler<SessionEventArgs> SessionStarted; - public event EventHandler<SessionEventArgs> CapabilitiesChanged; - public event EventHandler<SessionEventArgs> SessionEnded; - public event EventHandler<SessionEventArgs> SessionActivity; + private DtoOptions _itemInfoDtoOptions; + private bool _disposed = false; public SessionManager( + ILogger<SessionManager> logger, + IEventManager eventManager, IUserDataManager userDataManager, - ILoggerFactory loggerFactory, ILibraryManager libraryManager, IUserManager userManager, IMusicManager musicManager, IDtoService dtoService, IImageProcessor imageProcessor, - IJsonSerializer jsonSerializer, IServerApplicationHost appHost, - IHttpClient httpClient, - IAuthenticationRepository authRepo, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager) { + _logger = logger; + _eventManager = eventManager; _userDataManager = userDataManager; - _logger = loggerFactory.CreateLogger(nameof(SessionManager)); _libraryManager = libraryManager; _userManager = userManager; _musicManager = musicManager; _dtoService = dtoService; _imageProcessor = imageProcessor; - _jsonSerializer = jsonSerializer; _appHost = appHost; - _httpClient = httpClient; - _authRepo = authRepo; _deviceManager = deviceManager; _mediaSourceManager = mediaSourceManager; - _deviceManager.DeviceOptionsUpdated += _deviceManager_DeviceOptionsUpdated; + + _deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated; } - private void _deviceManager_DeviceOptionsUpdated(object sender, GenericEventArgs<Tuple<string, DeviceOptions>> e) + /// <inheritdoc /> + public event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationFailed; + + /// <inheritdoc /> + public event EventHandler<GenericEventArgs<AuthenticationResult>> AuthenticationSucceeded; + + /// <summary> + /// Occurs when playback has started. + /// </summary> + public event EventHandler<PlaybackProgressEventArgs> PlaybackStart; + + /// <summary> + /// Occurs when playback has progressed. + /// </summary> + public event EventHandler<PlaybackProgressEventArgs> PlaybackProgress; + + /// <summary> + /// Occurs when playback has stopped. + /// </summary> + public event EventHandler<PlaybackStopEventArgs> PlaybackStopped; + + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> SessionStarted; + + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> CapabilitiesChanged; + + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> SessionEnded; + + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> SessionActivity; + + /// <inheritdoc /> + public event EventHandler<SessionEventArgs> SessionControllerConnected; + + /// <summary> + /// Gets all connections. + /// </summary> + /// <value>All connections.</value> + public IEnumerable<SessionInfo> Sessions => _activeConnections.Values.OrderByDescending(c => c.LastActivityDate); + + private void OnDeviceManagerDeviceOptionsUpdated(object sender, GenericEventArgs<Tuple<string, DeviceOptions>> e) { foreach (var session in Sessions) { - if (string.Equals(session.DeviceId, e.Argument.Item1)) + if (string.Equals(session.DeviceId, e.Argument.Item1, StringComparison.Ordinal)) { if (!string.IsNullOrWhiteSpace(e.Argument.Item2.CustomName)) { @@ -138,14 +156,37 @@ namespace Emby.Server.Implementations.Session } } - private bool _disposed; + /// <inheritdoc /> public void Dispose() { + Dispose(true); + 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) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _idleTimer?.Dispose(); + } + + _idleTimer = null; + + _deviceManager.DeviceOptionsUpdated -= OnDeviceManagerDeviceOptionsUpdated; + _disposed = true; - _deviceManager.DeviceOptionsUpdated -= _deviceManager_DeviceOptionsUpdated; } - public void CheckDisposed() + private void CheckDisposed() { if (_disposed) { @@ -153,17 +194,11 @@ namespace Emby.Server.Implementations.Session } } - /// <summary> - /// Gets all connections. - /// </summary> - /// <value>All connections.</value> - public IEnumerable<SessionInfo> Sessions => _activeConnections.Values.OrderByDescending(c => c.LastActivityDate).ToList(); - private void OnSessionStarted(SessionInfo info) { if (!string.IsNullOrEmpty(info.DeviceId)) { - var capabilities = GetSavedCapabilities(info.DeviceId); + var capabilities = _deviceManager.GetCapabilities(info.DeviceId); if (capabilities != null) { @@ -171,33 +206,41 @@ namespace Emby.Server.Implementations.Session } } - EventHelper.QueueEventIfNotNull(SessionStarted, this, new SessionEventArgs - { - SessionInfo = info + _eventManager.Publish(new SessionStartedEventArgs(info)); - }, _logger); + EventHelper.QueueEventIfNotNull( + SessionStarted, + this, + new SessionEventArgs + { + SessionInfo = info + }, + _logger); } private void OnSessionEnded(SessionInfo info) { - EventHelper.QueueEventIfNotNull(SessionEnded, this, new SessionEventArgs - { - SessionInfo = info + EventHelper.QueueEventIfNotNull( + SessionEnded, + this, + new SessionEventArgs + { + SessionInfo = info + }, + _logger); - }, _logger); + _eventManager.Publish(new SessionEndedEventArgs(info)); info.Dispose(); } - public void UpdateDeviceName(string sessionId, string deviceName) + /// <inheritdoc /> + public void UpdateDeviceName(string sessionId, string reportedDeviceName) { var session = GetSession(sessionId); - - var key = GetSessionKey(session.Client, session.DeviceId); - if (session != null) { - session.DeviceName = deviceName; + session.DeviceName = reportedDeviceName; } } @@ -210,10 +253,9 @@ namespace Emby.Server.Implementations.Session /// <param name="deviceName">Name of the device.</param> /// <param name="remoteEndPoint">The remote end point.</param> /// <param name="user">The user.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">user</exception> - /// <exception cref="UnauthorizedAccessException"></exception> - public SessionInfo LogSessionActivity(string appName, + /// <returns>SessionInfo.</returns> + public async Task<SessionInfo> LogSessionActivity( + string appName, string appVersion, string deviceId, string deviceName, @@ -226,61 +268,80 @@ namespace Emby.Server.Implementations.Session { throw new ArgumentNullException(nameof(appName)); } + if (string.IsNullOrEmpty(appVersion)) { throw new ArgumentNullException(nameof(appVersion)); } + if (string.IsNullOrEmpty(deviceId)) { throw new ArgumentNullException(nameof(deviceId)); } var activityDate = DateTime.UtcNow; - var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user); + var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false); var lastActivityDate = session.LastActivityDate; session.LastActivityDate = activityDate; if (user != null) { var userLastActivityDate = user.LastActivityDate ?? DateTime.MinValue; - user.LastActivityDate = activityDate; if ((activityDate - userLastActivityDate).TotalSeconds > 60) { try { - _userManager.UpdateUser(user); + user.LastActivityDate = activityDate; + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); } - catch (Exception ex) + catch (DbUpdateConcurrencyException e) { - _logger.LogError("Error updating user", ex); + _logger.LogDebug(e, "Error updating user's last activity date."); } } } if ((activityDate - lastActivityDate).TotalSeconds > 10) { - SessionActivity?.Invoke(this, new SessionEventArgs - { - SessionInfo = session - }); + SessionActivity?.Invoke( + this, + new SessionEventArgs + { + SessionInfo = session + }); } return session; } + /// <inheritdoc /> + public void OnSessionControllerConnected(SessionInfo session) + { + EventHelper.QueueEventIfNotNull( + SessionControllerConnected, + this, + new SessionEventArgs + { + SessionInfo = session + }, + _logger); + } + + /// <inheritdoc /> public void CloseIfNeeded(SessionInfo session) { if (!session.SessionControllers.Any(i => i.IsSessionActive)) { var key = GetSessionKey(session.Client, session.DeviceId); - _activeConnections.TryRemove(key, out var removed); + _activeConnections.TryRemove(key, out _); OnSessionEnded(session); } } + /// <inheritdoc /> public void ReportSessionEnded(string sessionId) { CheckDisposed(); @@ -290,7 +351,7 @@ namespace Emby.Server.Implementations.Session { var key = GetSessionKey(session.Client, session.DeviceId); - _activeConnections.TryRemove(key, out var removed); + _activeConnections.TryRemove(key, out _); OnSessionEnded(session); } @@ -304,11 +365,12 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Updates the now playing item id. /// </summary> + /// <returns>Task.</returns> private async Task UpdateNowPlayingItem(SessionInfo session, PlaybackProgressInfo info, BaseItem libraryItem, bool updateLastCheckInTime) { if (string.IsNullOrEmpty(info.MediaSourceId)) { - info.MediaSourceId = info.ItemId.ToString("N"); + info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture); } if (!info.ItemId.Equals(Guid.Empty) && info.Item == null && libraryItem != null) @@ -320,8 +382,7 @@ namespace Emby.Server.Implementations.Session var runtimeTicks = libraryItem.RunTimeTicks; MediaSourceInfo mediaSource = null; - var hasMediaSources = libraryItem as IHasMediaSources; - if (hasMediaSources != null) + if (libraryItem is IHasMediaSources) { mediaSource = await GetMediaSource(libraryItem, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false); @@ -373,7 +434,6 @@ namespace Emby.Server.Implementations.Session /// Removes the now playing item id. /// </summary> /// <param name="session">The session.</param> - /// <exception cref="ArgumentNullException">item</exception> private void RemoveNowPlayingItem(SessionInfo session) { session.NowPlayingItem = null; @@ -386,9 +446,7 @@ namespace Emby.Server.Implementations.Session } private static string GetSessionKey(string appName, string deviceId) - { - return appName + deviceId; - } + => appName + deviceId; /// <summary> /// Gets the connection. @@ -400,7 +458,13 @@ namespace Emby.Server.Implementations.Session /// <param name="remoteEndPoint">The remote end point.</param> /// <param name="user">The user.</param> /// <returns>SessionInfo.</returns> - private SessionInfo GetSessionInfo(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, User user) + private async Task<SessionInfo> GetSessionInfo( + string appName, + string appVersion, + string deviceId, + string deviceName, + string remoteEndPoint, + User user) { CheckDisposed(); @@ -408,18 +472,20 @@ namespace Emby.Server.Implementations.Session { throw new ArgumentNullException(nameof(deviceId)); } + var key = GetSessionKey(appName, deviceId); CheckDisposed(); - var sessionInfo = _activeConnections.GetOrAdd(key, k => + if (!_activeConnections.TryGetValue(key, out var sessionInfo)) { - return CreateSession(k, appName, appVersion, deviceId, deviceName, remoteEndPoint, user); - }); + _activeConnections[key] = await CreateSession(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false); + sessionInfo = _activeConnections[key]; + } - sessionInfo.UserId = user == null ? Guid.Empty : user.Id; - sessionInfo.UserName = user == null ? null : user.Name; - sessionInfo.UserPrimaryImageTag = user == null ? null : GetImageCacheTag(user, ImageType.Primary); + sessionInfo.UserId = user?.Id ?? Guid.Empty; + sessionInfo.UserName = user?.Username; + sessionInfo.UserPrimaryImageTag = user?.ProfileImage == null ? null : GetImageCacheTag(user); sessionInfo.RemoteEndPoint = remoteEndPoint; sessionInfo.Client = appName; @@ -432,28 +498,35 @@ namespace Emby.Server.Implementations.Session if (user == null) { - sessionInfo.AdditionalUsers = new SessionUserInfo[] { }; + sessionInfo.AdditionalUsers = Array.Empty<SessionUserInfo>(); } return sessionInfo; } - private SessionInfo CreateSession(string key, string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, User user) + private async Task<SessionInfo> CreateSession( + string key, + string appName, + string appVersion, + string deviceId, + string deviceName, + string remoteEndPoint, + User user) { var sessionInfo = new SessionInfo(this, _logger) { Client = appName, DeviceId = deviceId, ApplicationVersion = appVersion, - Id = key.GetMD5().ToString("N"), + Id = key.GetMD5().ToString("N", CultureInfo.InvariantCulture), ServerId = _appHost.SystemId }; - var username = user == null ? null : user.Name; + var username = user?.Username; - sessionInfo.UserId = user == null ? Guid.Empty : user.Id; + sessionInfo.UserId = user?.Id ?? Guid.Empty; sessionInfo.UserName = username; - sessionInfo.UserPrimaryImageTag = user == null ? null : GetImageCacheTag(user, ImageType.Primary); + sessionInfo.UserPrimaryImageTag = user?.ProfileImage == null ? null : GetImageCacheTag(user); sessionInfo.RemoteEndPoint = remoteEndPoint; if (string.IsNullOrEmpty(deviceName)) @@ -461,7 +534,7 @@ namespace Emby.Server.Implementations.Session deviceName = "Network Device"; } - var deviceOptions = _deviceManager.GetDeviceOptions(deviceId); + var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false); if (string.IsNullOrEmpty(deviceOptions.CustomName)) { sessionInfo.DeviceName = deviceName; @@ -480,7 +553,7 @@ namespace Emby.Server.Implementations.Session { var users = new List<User>(); - if (!session.UserId.Equals(Guid.Empty)) + if (session.UserId != Guid.Empty) { var user = _userManager.GetUserById(session.UserId); @@ -499,15 +572,11 @@ namespace Emby.Server.Implementations.Session return users; } - private Timer _idleTimer; - private void StartIdleCheckTimer() { - if (_idleTimer == null) - { - _idleTimer = new Timer(CheckForIdlePlayback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); - } + _idleTimer ??= new Timer(CheckForIdlePlayback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); } + private void StopIdleCheckTimer() { if (_idleTimer != null) @@ -539,9 +608,9 @@ namespace Emby.Server.Implementations.Session Item = session.NowPlayingItem, ItemId = session.NowPlayingItem == null ? Guid.Empty : session.NowPlayingItem.Id, SessionId = session.Id, - MediaSourceId = session.PlayState == null ? null : session.PlayState.MediaSourceId, - PositionTicks = session.PlayState == null ? null : session.PlayState.PositionTicks - }); + MediaSourceId = session.PlayState?.MediaSourceId, + PositionTicks = session.PlayState?.PositionTicks + }).ConfigureAwait(false); } catch (Exception ex) { @@ -575,11 +644,11 @@ namespace Emby.Server.Implementations.Session } /// <summary> - /// Used to report that playback has started for an item + /// Used to report that playback has started for an item. /// </summary> /// <param name="info">The info.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">info</exception> + /// <exception cref="ArgumentNullException"><c>info</c> is <c>null</c>.</exception> public async Task OnPlaybackStart(PlaybackStartInfo info) { CheckDisposed(); @@ -591,7 +660,7 @@ namespace Emby.Server.Implementations.Session var session = GetSession(info.SessionId); - var libraryItem = info.ItemId.Equals(Guid.Empty) + var libraryItem = info.ItemId == Guid.Empty ? null : GetNowPlayingItem(session, info.ItemId); @@ -614,9 +683,7 @@ namespace Emby.Server.Implementations.Session } } - // Nothing to save here - // Fire events to inform plugins - EventHelper.QueueEventIfNotNull(PlaybackStart, this, new PlaybackProgressEventArgs + var eventArgs = new PlaybackStartEventArgs { Item = libraryItem, Users = users, @@ -626,8 +693,17 @@ namespace Emby.Server.Implementations.Session ClientName = session.Client, DeviceId = session.DeviceId, Session = session + }; - }, _logger); + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); + + // Nothing to save here + // Fire events to inform plugins + EventHelper.QueueEventIfNotNull( + PlaybackStart, + this, + eventArgs, + _logger); StartIdleCheckTimer(); } @@ -644,12 +720,9 @@ namespace Emby.Server.Implementations.Session data.PlayCount++; data.LastPlayedDate = DateTime.UtcNow; - if (item.SupportsPlayedStatus) + if (item.SupportsPlayedStatus && !item.SupportsPositionTicksResume) { - if (!(item is Video)) - { - data.Played = true; - } + data.Played = true; } else { @@ -659,14 +732,18 @@ namespace Emby.Server.Implementations.Session _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackStart, CancellationToken.None); } + /// <inheritdoc /> public Task OnPlaybackProgress(PlaybackProgressInfo info) { return OnPlaybackProgress(info, false); } /// <summary> - /// Used to report playback progress for an item + /// Used to report playback progress for an item. /// </summary> + /// <param name="info">The playback progress info.</param> + /// <param name="isAutomated">Whether this is an automated update.</param> + /// <returns>Task.</returns> public async Task OnPlaybackProgress(PlaybackProgressInfo info, bool isAutomated) { CheckDisposed(); @@ -695,7 +772,7 @@ namespace Emby.Server.Implementations.Session } } - PlaybackProgress?.Invoke(this, new PlaybackProgressEventArgs + var eventArgs = new PlaybackProgressEventArgs { Item = libraryItem, Users = users, @@ -709,7 +786,11 @@ namespace Emby.Server.Implementations.Session PlaySessionId = info.PlaySessionId, IsAutomated = isAutomated, Session = session - }); + }; + + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); + + PlaybackProgress?.Invoke(this, eventArgs); if (!isAutomated) { @@ -743,14 +824,13 @@ namespace Emby.Server.Implementations.Session { _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None); } - } private static bool UpdatePlaybackSettings(User user, PlaybackProgressInfo info, UserItemData data) { var changed = false; - if (user.Configuration.RememberAudioSelections) + if (user.RememberAudioSelections) { if (data.AudioStreamIndex != info.AudioStreamIndex) { @@ -767,7 +847,7 @@ namespace Emby.Server.Implementations.Session } } - if (user.Configuration.RememberSubtitleSelections) + if (user.RememberSubtitleSelections) { if (data.SubtitleStreamIndex != info.SubtitleStreamIndex) { @@ -788,12 +868,12 @@ namespace Emby.Server.Implementations.Session } /// <summary> - /// Used to report that playback has ended for an item + /// Used to report that playback has ended for an item. /// </summary> /// <param name="info">The info.</param> /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">info</exception> - /// <exception cref="ArgumentOutOfRangeException">positionTicks</exception> + /// <exception cref="ArgumentNullException"><c>info</c> is <c>null</c>.</exception> + /// <exception cref="ArgumentOutOfRangeException"><c>info.PositionTicks</c> is <c>null</c> or negative.</exception> public async Task OnPlaybackStopped(PlaybackStopInfo info) { CheckDisposed(); @@ -819,7 +899,7 @@ namespace Emby.Server.Implementations.Session // Normalize if (string.IsNullOrEmpty(info.MediaSourceId)) { - info.MediaSourceId = info.ItemId.ToString("N"); + info.MediaSourceId = info.ItemId.ToString("N", CultureInfo.InvariantCulture); } if (!info.ItemId.Equals(Guid.Empty) && info.Item == null && libraryItem != null) @@ -830,8 +910,7 @@ namespace Emby.Server.Implementations.Session { MediaSourceInfo mediaSource = null; - var hasMediaSources = libraryItem as IHasMediaSources; - if (hasMediaSources != null) + if (libraryItem is IHasMediaSources) { mediaSource = await GetMediaSource(libraryItem, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false); } @@ -848,7 +927,8 @@ namespace Emby.Server.Implementations.Session { var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown"; - _logger.LogInformation("Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms", + _logger.LogInformation( + "Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms", session.Client, session.ApplicationVersion, info.Item.Name, @@ -887,7 +967,7 @@ namespace Emby.Server.Implementations.Session } } - EventHelper.QueueEventIfNotNull(PlaybackStopped, this, new PlaybackStopEventArgs + var eventArgs = new PlaybackStopEventArgs { Item = libraryItem, Users = users, @@ -899,8 +979,11 @@ namespace Emby.Server.Implementations.Session ClientName = session.Client, DeviceId = session.DeviceId, Session = session + }; + + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); - }, _logger); + EventHelper.QueueEventIfNotNull(PlaybackStopped, this, eventArgs, _logger); } private bool OnPlaybackStopped(User user, BaseItem item, long? positionTicks, bool playbackFailed) @@ -936,14 +1019,17 @@ namespace Emby.Server.Implementations.Session /// <param name="sessionId">The session identifier.</param> /// <param name="throwOnMissing">if set to <c>true</c> [throw on missing].</param> /// <returns>SessionInfo.</returns> - /// <exception cref="ResourceNotFoundException"></exception> + /// <exception cref="ResourceNotFoundException"> + /// No session with an Id equal to <c>sessionId</c> was found + /// and <c>throwOnMissing</c> is <c>true</c>. + /// </exception> private SessionInfo GetSession(string sessionId, bool throwOnMissing = true) { - var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId)); - + var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal)); if (session == null && throwOnMissing) { - throw new ResourceNotFoundException(string.Format("Session {0} not found.", sessionId)); + throw new ResourceNotFoundException( + string.Format(CultureInfo.InvariantCulture, "Session {0} not found.", sessionId)); } return session; @@ -952,23 +1038,25 @@ namespace Emby.Server.Implementations.Session private SessionInfo GetSessionToRemoteControl(string sessionId) { // Accept either device id or session id - var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId)); + var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId, StringComparison.Ordinal)); if (session == null) { - throw new ResourceNotFoundException(string.Format("Session {0} not found.", sessionId)); + throw new ResourceNotFoundException( + string.Format(CultureInfo.InvariantCulture, "Session {0} not found.", sessionId)); } return session; } + /// <inheritdoc /> public Task SendMessageCommand(string controllingSessionId, string sessionId, MessageCommand command, CancellationToken cancellationToken) { CheckDisposed(); var generalCommand = new GeneralCommand { - Name = GeneralCommandType.DisplayMessage.ToString() + Name = GeneralCommandType.DisplayMessage }; generalCommand.Arguments["Header"] = command.Header; @@ -982,6 +1070,7 @@ namespace Emby.Server.Implementations.Session return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken); } + /// <inheritdoc /> public Task SendGeneralCommand(string controllingSessionId, string sessionId, GeneralCommand command, CancellationToken cancellationToken) { CheckDisposed(); @@ -994,27 +1083,46 @@ 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.ToArray(); - var messageId = Guid.NewGuid().ToString("N"); + var controllers = session.SessionControllers; + var messageId = Guid.NewGuid(); foreach (var controller in controllers) { - await controller.SendMessage(name, messageId, data, controllers, cancellationToken).ConfigureAwait(false); + await controller.SendMessage(name, messageId, data, cancellationToken).ConfigureAwait(false); + } + } + + private static Task SendMessageToSessions<T>(IEnumerable<SessionInfo> sessions, SessionMessageType name, T data, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + var messageId = Guid.NewGuid(); + foreach (var session in sessions) + { + var controllers = session.SessionControllers; + foreach (var controller in controllers) + { + yield return controller.SendMessage(name, messageId, data, cancellationToken); + } + } } + + return Task.WhenAll(GetTasks()); } + /// <inheritdoc /> public async Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken) { CheckDisposed(); var session = GetSessionToRemoteControl(sessionId); - var user = !session.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(session.UserId) : null; + var user = session.UserId == Guid.Empty ? null : _userManager.GetUserById(session.UserId); List<BaseItem> items; @@ -1039,7 +1147,7 @@ namespace Emby.Server.Implementations.Session if (command.PlayCommand == PlayCommand.PlayShuffle) { - items = items.OrderBy(i => Guid.NewGuid()).ToList(); + items.Shuffle(); command.PlayCommand = PlayCommand.PlayNow; } @@ -1049,30 +1157,32 @@ namespace Emby.Server.Implementations.Session { if (items.Any(i => i.GetPlayAccess(user) != PlayAccess.Full)) { - throw new ArgumentException(string.Format("{0} is not allowed to play media.", user.Name)); + throw new ArgumentException( + string.Format(CultureInfo.InvariantCulture, "{0} is not allowed to play media.", user.Username)); } } - if (user != null && command.ItemIds.Length == 1 && user.Configuration.EnableNextEpisodeAutoPlay) + if (user != null + && command.ItemIds.Length == 1 + && user.EnableNextEpisodeAutoPlay + && _libraryManager.GetItemById(command.ItemIds[0]) is Episode episode) { - var episode = _libraryManager.GetItemById(command.ItemIds[0]) as Episode; - if (episode != null) + var series = episode.Series; + if (series != null) { - var series = episode.Series; - if (series != null) + var episodes = series.GetEpisodes( + user, + new DtoOptions(false) + { + EnableImages = false + }) + .Where(i => !i.IsVirtualItem) + .SkipWhile(i => i.Id != episode.Id) + .ToList(); + + if (episodes.Count > 0) { - var episodes = series.GetEpisodes(user, new DtoOptions(false) - { - EnableImages = false - }) - .Where(i => !i.IsVirtualItem) - .SkipWhile(i => i.Id != episode.Id) - .ToList(); - - if (episodes.Count > 0) - { - command.ItemIds = episodes.Select(i => i.Id).ToArray(); - } + command.ItemIds = episodes.Select(i => i.Id).ToArray(); } } } @@ -1087,22 +1197,36 @@ 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) + { + CheckDisposed(); + var session = GetSession(sessionId); + await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false); + } + + /// <inheritdoc /> + public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken) + { + CheckDisposed(); + var session = GetSession(sessionId); + await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false); } - private IList<BaseItem> TranslateItemForPlayback(Guid id, User user) + private IEnumerable<BaseItem> TranslateItemForPlayback(Guid id, User user) { var item = _libraryManager.GetItemById(id); if (item == null) { _logger.LogError("A non-existant item Id {0} was passed into TranslateItemForPlayback", id); - return new List<BaseItem>(); + return Array.Empty<BaseItem>(); } - var byName = item as IItemByName; - - if (byName != null) + if (item is IItemByName byName) { return byName.GetTaggedItems(new InternalItemsQuery(user) { @@ -1111,13 +1235,13 @@ namespace Emby.Server.Implementations.Session DtoOptions = new DtoOptions(false) { EnableImages = false, - Fields = new ItemFields[] + Fields = new[] { ItemFields.SortName } }, IsVirtualItem = false, - OrderBy = new ValueTuple<string, SortOrder>[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) } + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } }); } @@ -1138,12 +1262,11 @@ namespace Emby.Server.Implementations.Session } }, IsVirtualItem = false, - OrderBy = new ValueTuple<string, SortOrder>[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) } - + OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) } }); } - return new List<BaseItem> { item }; + return new[] { item }; } private IEnumerable<BaseItem> TranslateItemForInstantMix(Guid id, User user) @@ -1152,27 +1275,31 @@ namespace Emby.Server.Implementations.Session if (item == null) { - _logger.LogError("A non-existant item Id {0} was passed into TranslateItemForInstantMix", id); + _logger.LogError("A non-existent item Id {0} was passed into TranslateItemForInstantMix", id); return new List<BaseItem>(); } return _musicManager.GetInstantMixFromItem(item, user, new DtoOptions(false) { EnableImages = false }); } + /// <inheritdoc /> public Task SendBrowseCommand(string controllingSessionId, string sessionId, BrowseRequest command, CancellationToken cancellationToken) { var generalCommand = new GeneralCommand { - Name = GeneralCommandType.DisplayContent.ToString() + Name = GeneralCommandType.DisplayContent, + Arguments = + { + ["ItemId"] = command.ItemId, + ["ItemName"] = command.ItemName, + ["ItemType"] = command.ItemType + } }; - generalCommand.Arguments["ItemId"] = command.ItemId; - generalCommand.Arguments["ItemName"] = command.ItemName; - generalCommand.Arguments["ItemType"] = command.ItemType; - return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken); } + /// <inheritdoc /> public Task SendPlaystateCommand(string controllingSessionId, string sessionId, PlaystateRequest command, CancellationToken cancellationToken) { CheckDisposed(); @@ -1185,19 +1312,20 @@ namespace Emby.Server.Implementations.Session AssertCanControl(session, controllingSession); if (!controllingSession.UserId.Equals(Guid.Empty)) { - command.ControllingUserId = controllingSession.UserId.ToString("N"); + command.ControllingUserId = controllingSession.UserId.ToString("N", CultureInfo.InvariantCulture); } } - return SendMessageToSession(session, "Playstate", command, cancellationToken); + return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken); } - private void AssertCanControl(SessionInfo session, SessionInfo controllingSession) + private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession) { if (session == null) { throw new ArgumentNullException(nameof(session)); } + if (controllingSession == null) { throw new ArgumentNullException(nameof(controllingSession)); @@ -1209,26 +1337,11 @@ namespace Emby.Server.Implementations.Session /// </summary> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - public async Task SendRestartRequiredNotification(CancellationToken cancellationToken) + public Task SendRestartRequiredNotification(CancellationToken cancellationToken) { CheckDisposed(); - var sessions = Sessions.ToList(); - - var tasks = sessions.Select(session => Task.Run(async () => - { - try - { - await SendMessageToSession(session, "RestartRequired", string.Empty, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError("Error in SendRestartRequiredNotification.", ex); - } - - }, cancellationToken)).ToArray(); - - await Task.WhenAll(tasks).ConfigureAwait(false); + return SendMessageToSessions(Sessions, SessionMessageType.RestartRequired, string.Empty, cancellationToken); } /// <summary> @@ -1240,22 +1353,7 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - var sessions = Sessions.ToList(); - - var tasks = sessions.Select(session => Task.Run(async () => - { - try - { - await SendMessageToSession(session, "ServerShuttingDown", string.Empty, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError("Error in SendServerShutdownNotification.", ex); - } - - }, cancellationToken)).ToArray(); - - return Task.WhenAll(tasks); + return SendMessageToSessions(Sessions, SessionMessageType.ServerShuttingDown, string.Empty, cancellationToken); } /// <summary> @@ -1269,22 +1367,7 @@ namespace Emby.Server.Implementations.Session _logger.LogDebug("Beginning SendServerRestartNotification"); - var sessions = Sessions.ToList(); - - var tasks = sessions.Select(session => Task.Run(async () => - { - try - { - await SendMessageToSession(session, "ServerRestarting", string.Empty, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError("Error in SendServerRestartNotification.", ex); - } - - }, cancellationToken)).ToArray(); - - return Task.WhenAll(tasks); + return SendMessageToSessions(Sessions, SessionMessageType.ServerRestarting, string.Empty, cancellationToken); } /// <summary> @@ -1300,12 +1383,12 @@ namespace Emby.Server.Implementations.Session var session = GetSession(sessionId); - if (session.UserId.Equals(userId)) + if (session.UserId == userId) { throw new ArgumentException("The requested user is already the primary user of the session."); } - if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId))) + if (session.AdditionalUsers.All(i => i.UserId != userId)) { var user = _userManager.GetUserById(userId); @@ -1314,7 +1397,7 @@ namespace Emby.Server.Implementations.Session list.Add(new SessionUserInfo { UserId = userId, - UserName = user.Name + UserName = user.Username }); session.AdditionalUsers = list.ToArray(); @@ -1353,14 +1436,19 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Authenticates the new session. /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{SessionInfo}.</returns> + /// <param name="request">The authenticationrequest.</param> + /// <returns>The authentication result.</returns> public Task<AuthenticationResult> AuthenticateNewSession(AuthenticationRequest request) { return AuthenticateNewSessionInternal(request, true); } - public Task<AuthenticationResult> CreateNewSession(AuthenticationRequest request) + /// <summary> + /// Directly authenticates the session without enforcing password. + /// </summary> + /// <param name="request">The authentication request.</param> + /// <returns>The authentication result.</returns> + public Task<AuthenticationResult> AuthenticateDirect(AuthenticationRequest request) { return AuthenticateNewSessionInternal(request, false); } @@ -1370,52 +1458,52 @@ namespace Emby.Server.Implementations.Session CheckDisposed(); User user = null; - if (!request.UserId.Equals(Guid.Empty)) + if (request.UserId != Guid.Empty) { - user = _userManager.Users - .FirstOrDefault(i => i.Id == request.UserId); + user = _userManager.GetUserById(request.UserId); } - if (user == null) + user ??= _userManager.GetUserByName(request.Username); + + if (enforcePassword) { - user = _userManager.Users - .FirstOrDefault(i => string.Equals(request.Username, i.Name, StringComparison.OrdinalIgnoreCase)); + user = await _userManager.AuthenticateUser( + request.Username, + request.Password, + null, + request.RemoteEndPoint, + true).ConfigureAwait(false); } - if (user != null) + if (user == null) { - // TODO: Move this to userManager? - if (!string.IsNullOrEmpty(request.DeviceId)) - { - if (!_deviceManager.CanAccessDevice(user, request.DeviceId)) - { - throw new SecurityException("User is not allowed access from this device."); - } - } + AuthenticationFailed?.Invoke(this, new GenericEventArgs<AuthenticationRequest>(request)); + throw new AuthenticationException("Invalid username or password entered."); } - if (enforcePassword) + if (!string.IsNullOrEmpty(request.DeviceId) + && !_deviceManager.CanAccessDevice(user, request.DeviceId)) { - var result = await _userManager.AuthenticateUser(request.Username, request.Password, request.PasswordSha1, request.RemoteEndPoint, true).ConfigureAwait(false); - - if (result == null) - { - AuthenticationFailed?.Invoke(this, new GenericEventArgs<AuthenticationRequest>(request)); - - throw new SecurityException("Invalid user or password entered."); - } + throw new SecurityException("User is not allowed access from this device."); + } - user = result; + 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 token = await GetAuthorizationToken(user, request.DeviceId, request.App, request.AppVersion, request.DeviceName).ConfigureAwait(false); - var session = LogSessionActivity(request.App, + var session = await LogSessionActivity( + request.App, request.AppVersion, request.DeviceId, request.DeviceName, request.RemoteEndPoint, - user); + user).ConfigureAwait(false); var returnResult = new AuthenticationResult { @@ -1430,21 +1518,21 @@ namespace Emby.Server.Implementations.Session return returnResult; } - private string GetAuthorizationToken(User user, string deviceId, string app, string appVersion, string deviceName) + private async Task<string> GetAuthorizationToken(User user, string deviceId, string app, string appVersion, string deviceName) { - var existing = _authRepo.Get(new AuthenticationInfoQuery - { - DeviceId = deviceId, - UserId = user.Id, - Limit = 1 - - }).Items.FirstOrDefault(); - - var allExistingForDevice = _authRepo.Get(new AuthenticationInfoQuery - { - DeviceId = deviceId + var existing = (await _deviceManager.GetDevices( + new DeviceQuery + { + DeviceId = deviceId, + UserId = user.Id, + Limit = 1 + }).ConfigureAwait(false)).Items.FirstOrDefault(); - }).Items; + var allExistingForDevice = (await _deviceManager.GetDevices( + new DeviceQuery + { + DeviceId = deviceId + }).ConfigureAwait(false)).Items; foreach (var auth in allExistingForDevice) { @@ -1452,43 +1540,29 @@ namespace Emby.Server.Implementations.Session { try { - Logout(auth); + await Logout(auth).ConfigureAwait(false); } - catch + catch (Exception ex) { - + _logger.LogError(ex, "Error while logging out."); } } } if (existing != null) { - _logger.LogInformation("Reissuing access token: " + existing.AccessToken); + _logger.LogInformation("Reissuing access token: {Token}", existing.AccessToken); return existing.AccessToken; } - var now = DateTime.UtcNow; - - var newToken = new AuthenticationInfo - { - AppName = app, - AppVersion = appVersion, - DateCreated = now, - DateLastActivity = now, - DeviceId = deviceId, - DeviceName = deviceName, - UserId = user.Id, - AccessToken = Guid.NewGuid().ToString("N"), - UserName = user.Name - }; - _logger.LogInformation("Creating new access token for user {0}", user.Id); - _authRepo.Create(newToken); + var device = await _deviceManager.CreateDevice(new Device(user.Id, app, appVersion, deviceName, deviceId)).ConfigureAwait(false); - return newToken.AccessToken; + return device.AccessToken; } - public void Logout(string accessToken) + /// <inheritdoc /> + public async Task Logout(string accessToken) { CheckDisposed(); @@ -1497,29 +1571,30 @@ namespace Emby.Server.Implementations.Session throw new ArgumentNullException(nameof(accessToken)); } - var existing = _authRepo.Get(new AuthenticationInfoQuery - { - Limit = 1, - AccessToken = accessToken - - }).Items.FirstOrDefault(); + var existing = (await _deviceManager.GetDevices( + new DeviceQuery + { + Limit = 1, + AccessToken = accessToken + }).ConfigureAwait(false)).Items; - if (existing != null) + if (existing.Count > 0) { - Logout(existing); + await Logout(existing[0]).ConfigureAwait(false); } } - public void Logout(AuthenticationInfo existing) + /// <inheritdoc /> + public async Task Logout(Device device) { CheckDisposed(); - _logger.LogInformation("Logging out access token {0}", existing.AccessToken); + _logger.LogInformation("Logging out access token {0}", device.AccessToken); - _authRepo.Delete(existing); + await _deviceManager.DeleteDevice(device).ConfigureAwait(false); var sessions = Sessions - .Where(i => string.Equals(i.DeviceId, existing.DeviceId, StringComparison.OrdinalIgnoreCase)) + .Where(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)) .ToList(); foreach (var session in sessions) @@ -1530,34 +1605,30 @@ namespace Emby.Server.Implementations.Session } catch (Exception ex) { - _logger.LogError("Error reporting session ended", ex); + _logger.LogError(ex, "Error reporting session ended"); } } } - public void RevokeUserTokens(Guid userId, string currentAccessToken) + /// <inheritdoc /> + public async Task RevokeUserTokens(Guid userId, string currentAccessToken) { CheckDisposed(); - var existing = _authRepo.Get(new AuthenticationInfoQuery + var existing = await _deviceManager.GetDevices(new DeviceQuery { UserId = userId - }); + }).ConfigureAwait(false); foreach (var info in existing.Items) { if (!string.Equals(currentAccessToken, info.AccessToken, StringComparison.OrdinalIgnoreCase)) { - Logout(info); + await Logout(info).ConfigureAwait(false); } } } - public void RevokeToken(string token) - { - Logout(token); - } - /// <summary> /// Reports the capabilities. /// </summary> @@ -1572,7 +1643,8 @@ namespace Emby.Server.Implementations.Session ReportCapabilities(session, capabilities, true); } - private void ReportCapabilities(SessionInfo session, + private void ReportCapabilities( + SessionInfo session, ClientCapabilities capabilities, bool saveCapabilities) { @@ -1580,36 +1652,19 @@ namespace Emby.Server.Implementations.Session if (saveCapabilities) { - CapabilitiesChanged?.Invoke(this, new SessionEventArgs - { - SessionInfo = session - }); + CapabilitiesChanged?.Invoke( + this, + new SessionEventArgs + { + 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); - } - - private DtoOptions _itemInfoDtoOptions; - /// <summary> - /// Converts a BaseItem to a BaseItemInfo + /// Converts a BaseItem to a BaseItemInfo. /// </summary> private BaseItemDto GetItemInfo(BaseItem item, MediaSourceInfo mediaSource) { @@ -1671,19 +1726,20 @@ namespace Emby.Server.Implementations.Session return info; } - private string GetImageCacheTag(BaseItem item, ImageType type) + private string GetImageCacheTag(User user) { try { - return _imageProcessor.GetImageCacheTag(item, type); + return _imageProcessor.GetImageCacheTag(user); } - catch (Exception ex) + catch (Exception e) { - _logger.LogError("Error getting {0} image info", ex, type); + _logger.LogError(e, "Error getting image information for profile image"); return null; } } + /// <inheritdoc /> public void ReportNowViewingItem(string sessionId, string itemId) { if (string.IsNullOrEmpty(itemId)) @@ -1691,23 +1747,17 @@ namespace Emby.Server.Implementations.Session throw new ArgumentNullException(nameof(itemId)); } - //var item = _libraryManager.GetItemById(new Guid(itemId)); - - //var info = GetItemInfo(item, null, null); - - //ReportNowViewingItem(sessionId, info); - } - - public void ReportNowViewingItem(string sessionId, BaseItemDto item) - { - //var session = GetSession(sessionId); + var item = _libraryManager.GetItemById(new Guid(itemId)); + var session = GetSession(sessionId); - //session.NowViewingItem = item; + session.NowViewingItem = GetItemInfo(item, null); } + /// <inheritdoc /> public void ReportTranscodingInfo(string deviceId, TranscodingInfo info) { - var session = Sessions.FirstOrDefault(i => string.Equals(i.DeviceId, deviceId)); + var session = Sessions.FirstOrDefault(i => + string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); if (session != null) { @@ -1715,25 +1765,29 @@ namespace Emby.Server.Implementations.Session } } + /// <inheritdoc /> public void ClearTranscodingInfo(string deviceId) { ReportTranscodingInfo(deviceId, null); } + /// <inheritdoc /> public SessionInfo GetSession(string deviceId, string client, string version) { - return Sessions.FirstOrDefault(i => string.Equals(i.DeviceId, deviceId) && - string.Equals(i.Client, client)); + return Sessions.FirstOrDefault(i => + string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase) + && string.Equals(i.Client, client, StringComparison.OrdinalIgnoreCase)); } - public SessionInfo GetSessionByAuthenticationToken(AuthenticationInfo info, string deviceId, string remoteEndpoint, string appVersion) + /// <inheritdoc /> + public Task<SessionInfo> GetSessionByAuthenticationToken(Device info, string deviceId, string remoteEndpoint, string appVersion) { if (info == null) { throw new ArgumentNullException(nameof(info)); } - var user = info.UserId.Equals(Guid.Empty) + var user = info.UserId == Guid.Empty ? null : _userManager.GetUserById(info.UserId); @@ -1758,33 +1812,38 @@ namespace Emby.Server.Implementations.Session return LogSessionActivity(appName, appVersion, deviceId, deviceName, remoteEndpoint, user); } - public SessionInfo GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint) + /// <inheritdoc /> + public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint) { - var result = _authRepo.Get(new AuthenticationInfoQuery + var items = (await _deviceManager.GetDevices(new DeviceQuery { - AccessToken = token - }); - - var info = result.Items.FirstOrDefault(); + AccessToken = token, + Limit = 1 + }).ConfigureAwait(false)).Items; - if (info == null) + if (items.Count == 0) { return null; } - return GetSessionByAuthenticationToken(info, deviceId, remoteEndpoint, null); + return await GetSessionByAuthenticationToken(items[0], deviceId, remoteEndpoint, null).ConfigureAwait(false); } - public Task SendMessageToAdminSessions<T>(string name, T data, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task SendMessageToAdminSessions<T>(SessionMessageType name, T data, CancellationToken cancellationToken) { CheckDisposed(); - var adminUserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id).ToList(); + var adminUserIds = _userManager.Users + .Where(i => i.HasPermission(PermissionKind.IsAdministrator)) + .Select(i => i.Id) + .ToList(); return SendMessageToUserSessions(adminUserIds, name, data, cancellationToken); } - public Task SendMessageToUserSessions<T>(List<Guid> userIds, string name, Func<T> dataFn, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, Func<T> dataFn, CancellationToken cancellationToken) { CheckDisposed(); @@ -1795,97 +1854,26 @@ namespace Emby.Server.Implementations.Session return Task.CompletedTask; } - var data = dataFn(); - - var tasks = sessions.Select(session => Task.Run(async () => - { - try - { - await SendMessageToSession(session, name, data, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError("Error sending message", ex); - } - - }, cancellationToken)).ToArray(); - - return Task.WhenAll(tasks); + return SendMessageToSessions(sessions, name, dataFn(), cancellationToken); } - public Task SendMessageToUserSessions<T>(List<Guid> userIds, string name, T data, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task SendMessageToUserSessions<T>(List<Guid> userIds, SessionMessageType name, T data, CancellationToken cancellationToken) { CheckDisposed(); - var sessions = Sessions.Where(i => userIds.Any(i.ContainsUser)).ToList(); - - var tasks = sessions.Select(session => Task.Run(async () => - { - try - { - await SendMessageToSession(session, name, data, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError("Error sending message", ex); - } - - }, cancellationToken)).ToArray(); - - return Task.WhenAll(tasks); + var sessions = Sessions.Where(i => userIds.Any(i.ContainsUser)); + return SendMessageToSessions(sessions, name, data, cancellationToken); } - public Task SendMessageToUserDeviceSessions<T>(string deviceId, string name, T data, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task SendMessageToUserDeviceSessions<T>(string deviceId, SessionMessageType name, T data, CancellationToken cancellationToken) { CheckDisposed(); - var sessions = Sessions.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)).ToList(); - - var tasks = sessions.Select(session => Task.Run(async () => - { - try - { - await SendMessageToSession(session, name, data, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError("Error sending message", ex); - } - - }, cancellationToken)).ToArray(); - - return Task.WhenAll(tasks); - } - - public Task SendMessageToUserDeviceAndAdminSessions<T>(string deviceId, string name, T data, CancellationToken cancellationToken) - { - CheckDisposed(); - - var sessions = Sessions - .Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase) || IsAdminSession(i)) - .ToList(); - - var tasks = sessions.Select(session => Task.Run(async () => - { - try - { - await SendMessageToSession(session, name, data, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError("Error sending message", ex); - } - - }, cancellationToken)).ToArray(); - - return Task.WhenAll(tasks); - } - - private bool IsAdminSession(SessionInfo s) - { - var user = _userManager.GetUserById(s.UserId); + var sessions = Sessions.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); - return user != null && user.Policy.IsAdministrator; + return SendMessageToSessions(sessions, name, data, cancellationToken); } } } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 24903f5e8..2a14a8c7b 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -1,72 +1,121 @@ +#nullable disable + using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Session { /// <summary> - /// Class SessionWebSocketListener + /// Class SessionWebSocketListener. /// </summary> - public class SessionWebSocketListener : IWebSocketListener, IDisposable + public sealed class SessionWebSocketListener : IWebSocketListener, IDisposable { /// <summary> - /// The _session manager + /// The timeout in seconds after which a WebSocket is considered to be lost. /// </summary> - private readonly ISessionManager _sessionManager; + 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> + private const float IntervalFactor = 0.2f; + + /// <summary> + /// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent. + /// </summary> + private const float ForceKeepAliveFactor = 0.75f; + + /// <summary> + /// Lock used for accesing the KeepAlive cancellation token. + /// </summary> + private readonly object _keepAliveLock = new object(); + + /// <summary> + /// The WebSocket watchlist. + /// </summary> + private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>(); /// <summary> - /// The _logger + /// Lock used for accessing the WebSockets watchlist. /// </summary> - private readonly ILogger _logger; + private readonly object _webSocketsLock = new object(); /// <summary> - /// The _dto service + /// The _session manager. /// </summary> - private readonly IJsonSerializer _json; + private readonly ISessionManager _sessionManager; - private readonly IHttpServer _httpServer; + /// <summary> + /// The _logger. + /// </summary> + private readonly ILogger<SessionWebSocketListener> _logger; + private readonly ILoggerFactory _loggerFactory; + /// <summary> + /// The KeepAlive cancellation token. + /// </summary> + private CancellationTokenSource _keepAliveCancellationToken; /// <summary> /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class. /// </summary> + /// <param name="logger">The logger.</param> /// <param name="sessionManager">The session manager.</param> /// <param name="loggerFactory">The logger factory.</param> - /// <param name="json">The json.</param> - /// <param name="httpServer">The HTTP server.</param> - public SessionWebSocketListener(ISessionManager sessionManager, ILoggerFactory loggerFactory, IJsonSerializer json, IHttpServer httpServer) + public SessionWebSocketListener( + ILogger<SessionWebSocketListener> logger, + ISessionManager sessionManager, + ILoggerFactory loggerFactory) { + _logger = logger; _sessionManager = sessionManager; - _logger = loggerFactory.CreateLogger(GetType().Name); - _json = json; - _httpServer = httpServer; - httpServer.WebSocketConnected += _serverManager_WebSocketConnected; + _loggerFactory = loggerFactory; } - void _serverManager_WebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e) + /// <inheritdoc /> + public void Dispose() { - var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint); + StopKeepAlive(); + } + /// <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 = await GetSession(connection.QueryString, connection.RemoteEndPoint.ToString()).ConfigureAwait(false); if (session != null) { - EnsureController(session, e.Argument); + EnsureController(session, connection); + await KeepAliveWebSocket(connection).ConfigureAwait(false); } else { - _logger.LogWarning("Unable to determine session based on url: {0}", e.Argument.Url); + _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString); } } - private SessionInfo GetSession(QueryParamCollection queryString, string remoteEndpoint) + private Task<SessionInfo> GetSession(IQueryCollection queryString, string remoteEndpoint) { if (queryString == null) { - throw new ArgumentNullException(nameof(queryString)); + return null; } var token = queryString["api_key"]; @@ -74,31 +123,224 @@ namespace Emby.Server.Implementations.Session { return null; } + var deviceId = queryString["deviceId"]; return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint); } - public void Dispose() + private void EnsureController(SessionInfo session, IWebSocketConnection connection) { - _httpServer.WebSocketConnected -= _serverManager_WebSocketConnected; + var controllerInfo = session.EnsureController<WebSocketController>( + s => new WebSocketController(_loggerFactory.CreateLogger<WebSocketController>(), s, _sessionManager)); + + var controller = (WebSocketController)controllerInfo.Item1; + controller.AddWebSocket(connection); + + _sessionManager.OnSessionControllerConnected(session); } /// <summary> - /// Processes the message. + /// Called when a WebSocket is closed. /// </summary> - /// <param name="message">The message.</param> + /// <param name="sender">The WebSocket.</param> + /// <param name="e">The event arguments.</param> + private void OnWebSocketClosed(object sender, EventArgs e) + { + var webSocket = (IWebSocketConnection)sender; + _logger.LogDebug("WebSocket {0} is closed.", webSocket); + RemoveWebSocket(webSocket); + } + + /// <summary> + /// Adds a WebSocket to the KeepAlive watchlist. + /// </summary> + /// <param name="webSocket">The WebSocket to monitor.</param> + private async Task KeepAliveWebSocket(IWebSocketConnection webSocket) + { + lock (_webSocketsLock) + { + if (!_webSockets.Add(webSocket)) + { + _logger.LogWarning("Multiple attempts to keep alive single WebSocket {0}", webSocket); + return; + } + + webSocket.Closed += OnWebSocketClosed; + webSocket.LastKeepAliveDate = DateTime.UtcNow; + + StartKeepAlive(); + } + + // Notify WebSocket about timeout + try + { + await SendForceKeepAlive(webSocket).ConfigureAwait(false); + } + catch (WebSocketException exception) + { + _logger.LogWarning(exception, "Cannot send ForceKeepAlive message to WebSocket {0}.", webSocket); + } + } + + /// <summary> + /// Removes a WebSocket from the KeepAlive watchlist. + /// </summary> + /// <param name="webSocket">The WebSocket to remove.</param> + private void RemoveWebSocket(IWebSocketConnection webSocket) + { + lock (_webSocketsLock) + { + if (!_webSockets.Remove(webSocket)) + { + _logger.LogWarning("WebSocket {0} not on watchlist.", webSocket); + } + else + { + webSocket.Closed -= OnWebSocketClosed; + } + } + } + + /// <summary> + /// Starts the KeepAlive watcher. + /// </summary> + private void StartKeepAlive() + { + lock (_keepAliveLock) + { + if (_keepAliveCancellationToken == null) + { + _keepAliveCancellationToken = new CancellationTokenSource(); + // Start KeepAlive watcher + _ = RepeatAsyncCallbackEvery( + KeepAliveSockets, + TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor), + _keepAliveCancellationToken.Token); + } + } + } + + /// <summary> + /// Stops the KeepAlive watcher. + /// </summary> + private void StopKeepAlive() + { + lock (_keepAliveLock) + { + if (_keepAliveCancellationToken != null) + { + _keepAliveCancellationToken.Cancel(); + _keepAliveCancellationToken.Dispose(); + _keepAliveCancellationToken = null; + } + } + + lock (_webSocketsLock) + { + foreach (var webSocket in _webSockets) + { + webSocket.Closed -= OnWebSocketClosed; + } + + _webSockets.Clear(); + } + } + + /// <summary> + /// Checks status of KeepAlive of WebSockets. + /// </summary> + private async Task KeepAliveSockets() + { + List<IWebSocketConnection> inactive; + List<IWebSocketConnection> lost; + + lock (_webSocketsLock) + { + _logger.LogDebug("Watching {0} WebSockets.", _webSockets.Count); + + inactive = _webSockets.Where(i => + { + var elapsed = (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds; + return (elapsed > WebSocketLostTimeout * ForceKeepAliveFactor) && (elapsed < WebSocketLostTimeout); + }).ToList(); + lost = _webSockets.Where(i => (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds >= WebSocketLostTimeout).ToList(); + } + + if (inactive.Count > 0) + { + _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count); + } + + foreach (var webSocket in inactive) + { + try + { + await SendForceKeepAlive(webSocket).ConfigureAwait(false); + } + catch (WebSocketException exception) + { + _logger.LogInformation(exception, "Error sending ForceKeepAlive message to WebSocket."); + lost.Add(webSocket); + } + } + + lock (_webSocketsLock) + { + if (lost.Count > 0) + { + _logger.LogInformation("Lost {0} WebSockets.", lost.Count); + foreach (var webSocket in lost) + { + // TODO: handle session relative to the lost webSocket + RemoveWebSocket(webSocket); + } + } + + if (_webSockets.Count == 0) + { + StopKeepAlive(); + } + } + } + + /// <summary> + /// Sends a ForceKeepAlive message to a WebSocket. + /// </summary> + /// <param name="webSocket">The WebSocket.</param> /// <returns>Task.</returns> - public Task ProcessMessage(WebSocketMessageInfo message) + private Task SendForceKeepAlive(IWebSocketConnection webSocket) { - return Task.CompletedTask; + return webSocket.SendAsync( + new WebSocketMessage<int> + { + MessageType = SessionMessageType.ForceKeepAlive, + Data = WebSocketLostTimeout + }, + CancellationToken.None); } - private void EnsureController(SessionInfo session, IWebSocketConnection connection) + /// <summary> + /// Runs a given async callback once every specified interval time, until cancelled. + /// </summary> + /// <param name="callback">The async callback.</param> + /// <param name="interval">The interval time.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task RepeatAsyncCallbackEvery(Func<Task> callback, TimeSpan interval, CancellationToken cancellationToken) { - var controllerInfo = session.EnsureController<WebSocketController>(s => new WebSocketController(s, _logger, _sessionManager)); + while (!cancellationToken.IsCancellationRequested) + { + await callback().ConfigureAwait(false); - var controller = (WebSocketController)controllerInfo.Item1; - controller.AddWebSocket(connection); + try + { + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + return; + } + } } } } diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs index 0d483c55f..9fa92a53a 100644 --- a/Emby.Server.Implementations/Session/WebSocketController.cs +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Linq; @@ -7,64 +9,68 @@ 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 { - public class WebSocketController : ISessionController, IDisposable + public sealed class WebSocketController : ISessionController, IDisposable { - public SessionInfo Session { get; private set; } - public IReadOnlyList<IWebSocketConnection> Sockets { get; private set; } - - private readonly ILogger _logger; - + private readonly ILogger<WebSocketController> _logger; private readonly ISessionManager _sessionManager; + private readonly SessionInfo _session; - public WebSocketController(SessionInfo session, ILogger logger, ISessionManager sessionManager) + private readonly List<IWebSocketConnection> _sockets; + private bool _disposed = false; + + public WebSocketController( + ILogger<WebSocketController> logger, + SessionInfo session, + ISessionManager sessionManager) { - Session = session; _logger = logger; + _session = session; _sessionManager = sessionManager; - Sockets = new List<IWebSocketConnection>(); + _sockets = new List<IWebSocketConnection>(); } private bool HasOpenSockets => GetActiveSockets().Any(); + /// <inheritdoc /> public bool SupportsMediaControl => HasOpenSockets; + /// <inheritdoc /> public bool IsSessionActive => HasOpenSockets; private IEnumerable<IWebSocketConnection> GetActiveSockets() - { - return Sockets - .OrderByDescending(i => i.LastActivityDate) - .Where(i => i.State == WebSocketState.Open); - } + => _sockets.Where(i => i.State == WebSocketState.Open); public void AddWebSocket(IWebSocketConnection connection) { - var sockets = Sockets.ToList(); - sockets.Add(connection); + _logger.LogDebug("Adding websocket to session {Session}", _session.Id); + _sockets.Add(connection); - Sockets = sockets; - - connection.Closed += connection_Closed; + connection.Closed += OnConnectionClosed; } - void connection_Closed(object sender, EventArgs e) + private void OnConnectionClosed(object? sender, EventArgs e) { - var connection = (IWebSocketConnection)sender; - var sockets = Sockets.ToList(); - sockets.Remove(connection); - - Sockets = sockets; - - _sessionManager.CloseIfNeeded(Session); + 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; + _sessionManager.CloseIfNeeded(_session); } - public Task SendMessage<T>(string name, string messageId, T data, ISessionController[] allControllers, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task SendMessage<T>( + SessionMessageType name, + Guid messageId, + T data, + CancellationToken cancellationToken) { var socket = GetActiveSockets() + .OrderByDescending(i => i.LastActivityDate) .FirstOrDefault(); if (socket == null) @@ -72,21 +78,30 @@ namespace Emby.Server.Implementations.Session return Task.CompletedTask; } - return socket.SendAsync(new WebSocketMessage<T> - { - Data = data, - MessageType = name, - MessageId = messageId - - }, cancellationToken); + return socket.SendAsync( + new WebSocketMessage<T> + { + Data = data, + MessageType = name, + MessageId = messageId + }, + cancellationToken); } + /// <inheritdoc /> public void Dispose() { - foreach (var socket in Sockets.ToList()) + if (_disposed) { - socket.Closed -= connection_Closed; + return; } + + foreach (var socket in _sockets) + { + socket.Closed -= OnConnectionClosed; + } + + _disposed = true; } } } diff --git a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs index 16507466f..db8b68949 100644 --- a/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs +++ b/Emby.Server.Implementations/Sorting/AiredEpisodeOrderComparer.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; @@ -9,12 +11,18 @@ namespace Emby.Server.Implementations.Sorting public class AiredEpisodeOrderComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.AiredEpisodeOrder; + + /// <summary> /// Compares the specified x. /// </summary> /// <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) { @@ -26,16 +34,6 @@ namespace Emby.Server.Implementations.Sorting throw new ArgumentNullException(nameof(y)); } - if (x.PremiereDate.HasValue && y.PremiereDate.HasValue) - { - var val = DateTime.Compare(x.PremiereDate.Value, y.PremiereDate.Value); - - if (val != 0) - { - //return val; - } - } - var episode1 = x as Episode; var episode2 = y as Episode; @@ -129,11 +127,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) @@ -152,16 +150,16 @@ namespace Emby.Server.Implementations.Sorting private static int CompareEpisodes(Episode x, Episode y) { - var xValue = (x.ParentIndexNumber ?? -1) * 1000 + (x.IndexNumber ?? -1); - var yValue = (y.ParentIndexNumber ?? -1) * 1000 + (y.IndexNumber ?? -1); + var xValue = ((x.ParentIndexNumber ?? -1) * 1000) + (x.IndexNumber ?? -1); + var yValue = ((y.ParentIndexNumber ?? -1) * 1000) + (y.IndexNumber ?? -1); + var comparisonResult = xValue.CompareTo(yValue); + // If equal, compare premiere dates + if (comparisonResult == 0 && x.PremiereDate.HasValue && y.PremiereDate.HasValue) + { + comparisonResult = DateTime.Compare(x.PremiereDate.Value, y.PremiereDate.Value); + } - return xValue.CompareTo(yValue); + return comparisonResult; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.AiredEpisodeOrder; } } diff --git a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs index 535f123f9..bd1966623 100644 --- a/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumArtistComparer.cs @@ -8,17 +8,23 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class AlbumArtistComparer + /// Class AlbumArtistComparer. /// </summary> public class AlbumArtistComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.AlbumArtist; + + /// <summary> /// Compares the specified x. /// </summary> /// <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,17 +34,11 @@ 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; - return audio != null ? audio.AlbumArtists.FirstOrDefault() : null; + return audio?.AlbumArtists.FirstOrDefault(); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.AlbumArtist; } } diff --git a/Emby.Server.Implementations/Sorting/AlbumComparer.cs b/Emby.Server.Implementations/Sorting/AlbumComparer.cs index 3831a0d2d..fe7dc84cb 100644 --- a/Emby.Server.Implementations/Sorting/AlbumComparer.cs +++ b/Emby.Server.Implementations/Sorting/AlbumComparer.cs @@ -7,17 +7,23 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class AlbumComparer + /// Class AlbumComparer. /// </summary> public class AlbumComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Album; + + /// <summary> /// Compares the specified x. /// </summary> /// <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,17 +33,11 @@ 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; return audio == null ? string.Empty : audio.Album; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Album; } } diff --git a/Emby.Server.Implementations/Sorting/AlphanumComparator.cs b/Emby.Server.Implementations/Sorting/AlphanumComparator.cs deleted file mode 100644 index 2e00c24d7..000000000 --- a/Emby.Server.Implementations/Sorting/AlphanumComparator.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Collections.Generic; -using System.Text; -using MediaBrowser.Controller.Sorting; - -namespace Emby.Server.Implementations.Sorting -{ - public class AlphanumComparator : IComparer<string> - { - public static int CompareValues(string s1, string s2) - { - if (s1 == null || s2 == null) - { - return 0; - } - - int thisMarker = 0, thisNumericChunk = 0; - int thatMarker = 0, thatNumericChunk = 0; - - while ((thisMarker < s1.Length) || (thatMarker < s2.Length)) - { - if (thisMarker >= s1.Length) - { - return -1; - } - else if (thatMarker >= s2.Length) - { - return 1; - } - char thisCh = s1[thisMarker]; - char thatCh = s2[thatMarker]; - - var thisChunk = new StringBuilder(); - var thatChunk = new StringBuilder(); - - while ((thisMarker < s1.Length) && (thisChunk.Length == 0 || SortHelper.InChunk(thisCh, thisChunk[0]))) - { - thisChunk.Append(thisCh); - thisMarker++; - - if (thisMarker < s1.Length) - { - thisCh = s1[thisMarker]; - } - } - - while ((thatMarker < s2.Length) && (thatChunk.Length == 0 || SortHelper.InChunk(thatCh, thatChunk[0]))) - { - thatChunk.Append(thatCh); - thatMarker++; - - if (thatMarker < s2.Length) - { - thatCh = s2[thatMarker]; - } - } - - int result = 0; - // If both chunks contain numeric characters, sort them numerically - if (char.IsDigit(thisChunk[0]) && char.IsDigit(thatChunk[0])) - { - if (!int.TryParse(thisChunk.ToString(), out thisNumericChunk)) - { - return 0; - } - if (!int.TryParse(thatChunk.ToString(), out thatNumericChunk)) - { - return 0; - } - - if (thisNumericChunk < thatNumericChunk) - { - result = -1; - } - - if (thisNumericChunk > thatNumericChunk) - { - result = 1; - } - } - else - { - result = thisChunk.ToString().CompareTo(thatChunk.ToString()); - } - - if (result != 0) - { - return result; - } - } - - return 0; - } - - public int Compare(string x, string y) - { - return CompareValues(x, y); - } - } -} diff --git a/Emby.Server.Implementations/Sorting/ArtistComparer.cs b/Emby.Server.Implementations/Sorting/ArtistComparer.cs index 9d5befc9a..7b7ba5753 100644 --- a/Emby.Server.Implementations/Sorting/ArtistComparer.cs +++ b/Emby.Server.Implementations/Sorting/ArtistComparer.cs @@ -7,17 +7,15 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class ArtistComparer + /// Class ArtistComparer. /// </summary> public class ArtistComparer : IBaseItemComparer { - /// <summary> - /// Compares the specified x. - /// </summary> - /// <param name="x">The x.</param> - /// <param name="y">The y.</param> - /// <returns>System.Int32.</returns> - public int Compare(BaseItem x, BaseItem y) + /// <inheritdoc /> + public string Name => ItemSortBy.Artist; + + /// <inheritdoc /> + public int Compare(BaseItem? x, BaseItem? y) { return string.Compare(GetValue(x), GetValue(y), StringComparison.CurrentCultureIgnoreCase); } @@ -27,22 +25,14 @@ 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; - - if (audio == null) + if (x is not Audio audio) { return string.Empty; } - return audio.Artists.Length == 0 ? null : audio.Artists[0]; + return audio.Artists.Count == 0 ? null : audio.Artists[0]; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Artist; } } diff --git a/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs index 87d3ae2d6..5f142fa4b 100644 --- a/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/CommunityRatingComparer.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; @@ -8,26 +10,30 @@ namespace Emby.Server.Implementations.Sorting public class CommunityRatingComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.CommunityRating; + + /// <summary> /// Compares the specified x. /// </summary> /// <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) + { throw new ArgumentNullException(nameof(x)); + } if (y == null) + { throw new ArgumentNullException(nameof(y)); + } return (x.CommunityRating ?? 0).CompareTo(y.CommunityRating ?? 0); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.CommunityRating; } } diff --git a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs index adb78dec5..ba1835e4f 100644 --- a/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/CriticRatingComparer.cs @@ -5,30 +5,30 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class CriticRatingComparer + /// Class CriticRatingComparer. /// </summary> public class CriticRatingComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.CriticRating; + + /// <summary> /// Compares the specified x. /// </summary> /// <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> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.CriticRating; } } diff --git a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs index 8501bd9ee..8b460166c 100644 --- a/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateCreatedComparer.cs @@ -6,31 +6,35 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class DateCreatedComparer + /// Class DateCreatedComparer. /// </summary> public class DateCreatedComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.DateCreated; + + /// <summary> /// Compares the specified x. /// </summary> /// <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) + { throw new ArgumentNullException(nameof(x)); + } if (y == null) + { throw new ArgumentNullException(nameof(y)); + } return DateTime.Compare(x.DateCreated, y.DateCreated); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.DateCreated; } } diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs index 623675157..b1cb123ce 100644 --- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs @@ -1,4 +1,8 @@ +#nullable disable +#pragma warning disable CS1591 + using System; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; @@ -27,6 +31,12 @@ namespace Emby.Server.Implementations.Sorting public IUserDataManager UserDataRepository { get; set; } /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.DateLastContentAdded; + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -44,9 +54,7 @@ namespace Emby.Server.Implementations.Sorting /// <returns>DateTime.</returns> private static DateTime GetDate(BaseItem x) { - var folder = x as Folder; - - if (folder != null) + if (x is Folder folder) { if (folder.DateLastMediaAdded.HasValue) { @@ -56,11 +64,5 @@ namespace Emby.Server.Implementations.Sorting return DateTime.MinValue; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.DateLastContentAdded; } } diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs index 73f59f8cd..ec818253b 100644 --- a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs @@ -1,4 +1,7 @@ +#nullable disable + using System; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; @@ -7,7 +10,7 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class DatePlayedComparer + /// Class DatePlayedComparer. /// </summary> public class DatePlayedComparer : IUserBaseItemComparer { @@ -30,6 +33,12 @@ namespace Emby.Server.Implementations.Sorting public IUserDataManager UserDataRepository { get; set; } /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.DatePlayed; + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -56,11 +65,5 @@ namespace Emby.Server.Implementations.Sorting return DateTime.MinValue; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.DatePlayed; } } diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs index 66de05a6a..73e628cf7 100644 --- a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs @@ -1,3 +1,7 @@ +#nullable disable +#pragma warning disable CS1591 + +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; @@ -14,6 +18,24 @@ namespace Emby.Server.Implementations.Sorting public User User { get; set; } /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.IsFavoriteOrLiked; + + /// <summary> + /// Gets or sets the user data repository. + /// </summary> + /// <value>The user data repository.</value> + public IUserDataManager UserDataRepository { get; set; } + + /// <summary> + /// Gets or sets the user manager. + /// </summary> + /// <value>The user manager.</value> + public IUserManager UserManager { get; set; } + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -33,23 +55,5 @@ namespace Emby.Server.Implementations.Sorting { return x.IsFavoriteOrLiked(User) ? 0 : 1; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.IsFavoriteOrLiked; - - /// <summary> - /// Gets or sets the user data repository. - /// </summary> - /// <value>The user data repository.</value> - public IUserDataManager UserDataRepository { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } } } diff --git a/Emby.Server.Implementations/Sorting/IsFolderComparer.cs b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs index dfaa144cd..3c5ddeefa 100644 --- a/Emby.Server.Implementations/Sorting/IsFolderComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsFolderComparer.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Querying; @@ -7,12 +9,18 @@ namespace Emby.Server.Implementations.Sorting public class IsFolderComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.IsFolder; + + /// <summary> /// Compares the specified x. /// </summary> /// <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)); } @@ -22,15 +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; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.IsFolder; } } diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs index da3f3dd25..7d77a8bc5 100644 --- a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs @@ -1,3 +1,8 @@ +#nullable disable + +#pragma warning disable CS1591 + +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; @@ -14,6 +19,24 @@ namespace Emby.Server.Implementations.Sorting public User User { get; set; } /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.IsUnplayed; + + /// <summary> + /// Gets or sets the user data repository. + /// </summary> + /// <value>The user data repository.</value> + public IUserDataManager UserDataRepository { get; set; } + + /// <summary> + /// Gets or sets the user manager. + /// </summary> + /// <value>The user manager.</value> + public IUserManager UserManager { get; set; } + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -33,23 +56,5 @@ namespace Emby.Server.Implementations.Sorting { return x.IsPlayed(User) ? 0 : 1; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.IsUnplayed; - - /// <summary> - /// Gets or sets the user data repository. - /// </summary> - /// <value>The user data repository.</value> - public IUserDataManager UserDataRepository { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } } } diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs index d99d0eff2..926835f90 100644 --- a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs +++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs @@ -1,3 +1,8 @@ +#nullable disable + +#pragma warning disable CS1591 + +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; @@ -14,6 +19,24 @@ namespace Emby.Server.Implementations.Sorting public User User { get; set; } /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.IsUnplayed; + + /// <summary> + /// Gets or sets the user data repository. + /// </summary> + /// <value>The user data repository.</value> + public IUserDataManager UserDataRepository { get; set; } + + /// <summary> + /// Gets or sets the user manager. + /// </summary> + /// <value>The user manager.</value> + public IUserManager UserManager { get; set; } + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -33,23 +56,5 @@ namespace Emby.Server.Implementations.Sorting { return x.IsUnplayed(User) ? 0 : 1; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.IsUnplayed; - - /// <summary> - /// Gets or sets the user data repository. - /// </summary> - /// <value>The user data repository.</value> - public IUserDataManager UserDataRepository { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } } } diff --git a/Emby.Server.Implementations/Sorting/NameComparer.cs b/Emby.Server.Implementations/Sorting/NameComparer.cs index 10fa4359a..8f87717f4 100644 --- a/Emby.Server.Implementations/Sorting/NameComparer.cs +++ b/Emby.Server.Implementations/Sorting/NameComparer.cs @@ -6,31 +6,35 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class NameComparer + /// Class NameComparer. /// </summary> public class NameComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Name; + + /// <summary> /// Compares the specified x. /// </summary> /// <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) + { throw new ArgumentNullException(nameof(x)); + } if (y == null) + { throw new ArgumentNullException(nameof(y)); + } return string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Name; } } diff --git a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs index e8fa8edc8..a81f78ebf 100644 --- a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs +++ b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs @@ -1,3 +1,5 @@ +#pragma warning disable CS1591 + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; @@ -16,29 +18,33 @@ namespace Emby.Server.Implementations.Sorting } /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.OfficialRating; + + /// <summary> /// Compares the specified x. /// </summary> /// <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) + { throw new ArgumentNullException(nameof(x)); + } if (y == null) + { throw new ArgumentNullException(nameof(y)); + } var levelX = string.IsNullOrEmpty(x.OfficialRating) ? 0 : _localization.GetRatingLevel(x.OfficialRating) ?? 0; var levelY = string.IsNullOrEmpty(y.OfficialRating) ? 0 : _localization.GetRatingLevel(y.OfficialRating) ?? 0; return levelX.CompareTo(levelY); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.OfficialRating; } } diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs index eb74ce1bd..45c9044c5 100644 --- a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs +++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs @@ -1,3 +1,6 @@ +#nullable disable + +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Sorting; @@ -6,7 +9,7 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class PlayCountComparer + /// Class PlayCountComparer. /// </summary> public class PlayCountComparer : IUserBaseItemComparer { @@ -17,6 +20,24 @@ namespace Emby.Server.Implementations.Sorting public User User { get; set; } /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.PlayCount; + + /// <summary> + /// Gets or sets the user data repository. + /// </summary> + /// <value>The user data repository.</value> + public IUserDataManager UserDataRepository { get; set; } + + /// <summary> + /// Gets or sets the user manager. + /// </summary> + /// <value>The user manager.</value> + public IUserManager UserManager { get; set; } + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -38,23 +59,5 @@ namespace Emby.Server.Implementations.Sorting return userdata == null ? 0 : userdata.PlayCount; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.PlayCount; - - /// <summary> - /// Gets or sets the user data repository. - /// </summary> - /// <value>The user data repository.</value> - public IUserDataManager UserDataRepository { get; set; } - - /// <summary> - /// Gets or sets the user manager. - /// </summary> - /// <value>The user manager.</value> - public IUserManager UserManager { get; set; } } } diff --git a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs index 0c944a7a0..b217556ef 100644 --- a/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/PremiereDateComparer.cs @@ -6,17 +6,23 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class PremiereDateComparer + /// Class PremiereDateComparer. /// </summary> public class PremiereDateComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.PremiereDate; + + /// <summary> /// Compares the specified x. /// </summary> /// <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 +32,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; @@ -44,13 +55,8 @@ namespace Emby.Server.Implementations.Sorting // Don't blow up if the item has a bad ProductionYear, just return MinValue } } + return DateTime.MinValue; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.PremiereDate; } } diff --git a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs index 472a07eb3..d2022df7a 100644 --- a/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs +++ b/Emby.Server.Implementations/Sorting/ProductionYearComparer.cs @@ -5,17 +5,23 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class ProductionYearComparer + /// Class ProductionYearComparer. /// </summary> public class ProductionYearComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.ProductionYear; + + /// <summary> /// Compares the specified x. /// </summary> /// <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 +31,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; @@ -39,11 +50,5 @@ namespace Emby.Server.Implementations.Sorting return 0; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.ProductionYear; } } diff --git a/Emby.Server.Implementations/Sorting/RandomComparer.cs b/Emby.Server.Implementations/Sorting/RandomComparer.cs index bde8b4534..bf0168222 100644 --- a/Emby.Server.Implementations/Sorting/RandomComparer.cs +++ b/Emby.Server.Implementations/Sorting/RandomComparer.cs @@ -6,25 +6,25 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class RandomComparer + /// Class RandomComparer. /// </summary> public class RandomComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Random; + + /// <summary> /// Compares the specified x. /// </summary> /// <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()); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Random; } } diff --git a/Emby.Server.Implementations/Sorting/RuntimeComparer.cs b/Emby.Server.Implementations/Sorting/RuntimeComparer.cs index 1d2bdde26..e32e5552e 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; @@ -6,11 +8,17 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class RuntimeComparer + /// Class RuntimeComparer. /// </summary> public class RuntimeComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Runtime; + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -19,18 +27,16 @@ namespace Emby.Server.Implementations.Sorting public int Compare(BaseItem x, BaseItem y) { if (x == null) + { throw new ArgumentNullException(nameof(x)); + } if (y == null) + { throw new ArgumentNullException(nameof(y)); + } return (x.RunTimeTicks ?? 0).CompareTo(y.RunTimeTicks ?? 0); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Runtime; } } diff --git a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs index 46e0dd918..4123a59f8 100644 --- a/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs +++ b/Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; @@ -8,6 +12,12 @@ namespace Emby.Server.Implementations.Sorting public class SeriesSortNameComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.SeriesSortName; + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -22,13 +32,7 @@ namespace Emby.Server.Implementations.Sorting { var hasSeries = item as IHasSeries; - return hasSeries != null ? hasSeries.FindSeriesSortName() : null; + return hasSeries?.FindSeriesSortName(); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.SeriesSortName; } } diff --git a/Emby.Server.Implementations/Sorting/SortNameComparer.cs b/Emby.Server.Implementations/Sorting/SortNameComparer.cs index cc0571c78..fb97a0349 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; @@ -6,11 +8,17 @@ using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { /// <summary> - /// Class SortNameComparer + /// Class SortNameComparer. /// </summary> public class SortNameComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.SortName; + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -19,18 +27,16 @@ namespace Emby.Server.Implementations.Sorting public int Compare(BaseItem x, BaseItem y) { if (x == null) + { throw new ArgumentNullException(nameof(x)); + } if (y == null) + { throw new ArgumentNullException(nameof(y)); + } return string.Compare(x.SortName, y.SortName, StringComparison.CurrentCultureIgnoreCase); } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.SortName; } } diff --git a/Emby.Server.Implementations/Sorting/StartDateComparer.cs b/Emby.Server.Implementations/Sorting/StartDateComparer.cs index aa040fa15..c3df7c47e 100644 --- a/Emby.Server.Implementations/Sorting/StartDateComparer.cs +++ b/Emby.Server.Implementations/Sorting/StartDateComparer.cs @@ -1,3 +1,7 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.LiveTv; @@ -9,6 +13,12 @@ namespace Emby.Server.Implementations.Sorting public class StartDateComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.StartDate; + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -26,19 +36,12 @@ namespace Emby.Server.Implementations.Sorting /// <returns>DateTime.</returns> private static DateTime GetDate(BaseItem x) { - var hasStartDate = x as LiveTvProgram; - - if (hasStartDate != null) + if (x is LiveTvProgram hasStartDate) { return hasStartDate.StartDate; } + return DateTime.MinValue; } - - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.StartDate; } } diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 617ed55d5..4d89cfa8b 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -1,5 +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; @@ -9,6 +14,12 @@ namespace Emby.Server.Implementations.Sorting public class StudioComparer : IBaseItemComparer { /// <summary> + /// Gets the name. + /// </summary> + /// <value>The name.</value> + public string Name => ItemSortBy.Studio; + + /// <summary> /// Compares the specified x. /// </summary> /// <param name="x">The x.</param> @@ -17,17 +28,16 @@ namespace Emby.Server.Implementations.Sorting public int Compare(BaseItem x, BaseItem y) { if (x == null) + { throw new ArgumentNullException(nameof(x)); + } if (y == null) + { throw new ArgumentNullException(nameof(y)); - return AlphanumComparator.CompareValues(x.Studios.FirstOrDefault() ?? string.Empty, y.Studios.FirstOrDefault() ?? string.Empty); - } + } - /// <summary> - /// Gets the name. - /// </summary> - /// <value>The name.</value> - public string Name => ItemSortBy.Studio; + return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); + } } } diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs new file mode 100644 index 000000000..75cf890e5 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -0,0 +1,688 @@ +#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="fromId">The current session identifier.</param> + /// <param name="type">The filtering type.</param> + /// <returns>The list of sessions matching the filter.</returns> + private IEnumerable<string> FilterSessions(string fromId, SyncPlayBroadcastType type) + { + return type switch + { + SyncPlayBroadcastType.CurrentSession => new string[] { fromId }, + SyncPlayBroadcastType.AllGroup => _participants + .Values + .Select(member => member.SessionId), + SyncPlayBroadcastType.AllExceptCurrentSession => _participants + .Values + .Select(member => member.SessionId) + .Where(sessionId => !sessionId.Equals(fromId, StringComparison.OrdinalIgnoreCase)), + SyncPlayBroadcastType.AllReady => _participants + .Values + .Where(member => !member.IsBuffering) + .Select(member => member.SessionId), + _ => Enumerable.Empty<string>() + }; + } + + /// <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.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.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 sessionId in FilterSessions(from.Id, type)) + { + yield return _sessionManager.SendSyncPlayGroupUpdate(sessionId, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// <inheritdoc /> + public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + foreach (var sessionId in FilterSessions(from.Id, type)) + { + yield return _sessionManager.SendSyncPlayCommand(sessionId, 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 void ClearPlayQueue(bool clearPlayingItem) + { + PlayQueue.ClearPlaylist(clearPlayingItem); + if (clearPlayingItem) + { + RestartCurrentItem(); + } + } + + /// <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; + var isPlaying = _state.Type.Equals(GroupStateType.Playing); + + if (isPlaying) + { + 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, + isPlaying, + PlayQueue.ShuffleMode, + PlayQueue.RepeatMode); + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs new file mode 100644 index 000000000..2ebeea717 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -0,0 +1,396 @@ +#nullable disable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +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; + +namespace Emby.Server.Implementations.SyncPlay +{ + /// <summary> + /// Class SyncPlayManager. + /// </summary> + public class SyncPlayManager : ISyncPlayManager, IDisposable + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<SyncPlayManager> _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 map between users and counter of active sessions. + /// </summary> + private readonly ConcurrentDictionary<Guid, int> _activeUsers = + new ConcurrentDictionary<Guid, int>(); + + /// <summary> + /// The map between sessions and groups. + /// </summary> + private readonly ConcurrentDictionary<string, Group> _sessionToGroupMap = + new ConcurrentDictionary<string, Group>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// The groups. + /// </summary> + private readonly ConcurrentDictionary<Guid, Group> _groups = + new ConcurrentDictionary<Guid, Group>(); + + /// <summary> + /// 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; + + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayManager" /> 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 SyncPlayManager( + ILoggerFactory loggerFactory, + IUserManager userManager, + ISessionManager sessionManager, + ILibraryManager libraryManager) + { + _loggerFactory = loggerFactory; + _userManager = userManager; + _sessionManager = sessionManager; + _libraryManager = libraryManager; + _logger = loggerFactory.CreateLogger<SyncPlayManager>(); + _sessionManager.SessionEnded += OnSessionEnded; + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <inheritdoc /> + public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + // Locking required to access list of groups. + lock (_groupsLock) + { + // Make sure that session has not joined another group. + if (_sessionToGroupMap.ContainsKey(session.Id)) + { + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, cancellationToken); + } + + var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager); + _groups[group.GroupId] = group; + + if (!_sessionToGroupMap.TryAdd(session.Id, group)) + { + throw new InvalidOperationException("Could not add session to group!"); + } + + UpdateSessionsCounter(session.UserId, 1); + group.CreateGroup(session, request, cancellationToken); + } + } + + /// <inheritdoc /> + public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + var user = _userManager.GetUserById(session.UserId); + + // Locking required to access list of groups. + lock (_groupsLock) + { + _groups.TryGetValue(request.GroupId, out Group group); + + if (group == null) + { + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId); + + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + return; + } + + // Group lock required to let other requests end first. + lock (group) + { + 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>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + return; + } + + if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup)) + { + 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 (!_sessionToGroupMap.TryAdd(session.Id, group)) + { + throw new InvalidOperationException("Could not add session to group!"); + } + + UpdateSessionsCounter(session.UserId, 1); + group.SessionJoin(session, request, cancellationToken); + } + } + } + + /// <inheritdoc /> + public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + // 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) + { + 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 _); + } + } + } + else + { + _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.Id, error, CancellationToken.None); + return; + } + } + } + + /// <inheritdoc /> + public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + var user = _userManager.GetUserById(session.UserId); + List<GroupInfoDto> list = new List<GroupInfoDto>(); + + lock (_groupsLock) + { + 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, IGroupPlaybackRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + // Group lock required as Group is not thread-safe. + lock (group) + { + // 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; + } + + // Drop request if group is empty. + if (group.IsGroupEmpty()) + { + return; + } + + // Apply requested changes to group. + group.HandleRequest(session, request, cancellationToken); + } + } + else + { + _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.Id, error, CancellationToken.None); + } + } + + /// <inheritdoc /> + public bool IsUserActive(Guid userId) + { + if (_activeUsers.TryGetValue(userId, out var sessionsCounter)) + { + return sessionsCounter > 0; + } + else + { + return false; + } + } + + /// <summary> + /// Releases unmanaged and optionally managed resources. + /// </summary> + /// <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; + } + + 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) + { + // Update sessions counter. + var newSessionsCounter = _activeUsers.AddOrUpdate( + userId, + 1, + (key, sessionsCounter) => sessionsCounter + toAdd); + + // Should never happen. + if (newSessionsCounter < 0) + { + throw new InvalidOperationException("Sessions counter is negative!"); + } + + // Clean record if user has no more active sessions. + if (newSessionsCounter == 0) + { + _activeUsers.TryRemove(new KeyValuePair<Guid, int>(userId, newSessionsCounter)); + } + } + } +} diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 630ef4893..4d990c871 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -1,14 +1,20 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Generic; using System.Linq; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; 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; namespace Emby.Server.Implementations.TV { @@ -17,19 +23,19 @@ namespace Emby.Server.Implementations.TV private readonly IUserManager _userManager; private readonly IUserDataManager _userDataManager; private readonly ILibraryManager _libraryManager; - private readonly IServerConfigurationManager _config; + private readonly IServerConfigurationManager _configurationManager; - public TVSeriesManager(IUserManager userManager, IUserDataManager userDataManager, ILibraryManager libraryManager, IServerConfigurationManager config) + public TVSeriesManager(IUserManager userManager, IUserDataManager userDataManager, ILibraryManager libraryManager, IServerConfigurationManager configurationManager) { _userManager = userManager; _userDataManager = userDataManager; _libraryManager = libraryManager; - _config = config; + _configurationManager = configurationManager; } - public QueryResult<BaseItem> GetNextUp(NextUpQuery request, DtoOptions dtoOptions) + public QueryResult<BaseItem> GetNextUp(NextUpQuery query, DtoOptions options) { - var user = _userManager.GetUserById(request.UserId); + var user = _userManager.GetUserById(query.UserId); if (user == null) { @@ -37,11 +43,9 @@ namespace Emby.Server.Implementations.TV } string presentationUniqueKey = null; - if (!string.IsNullOrEmpty(request.SeriesId)) + if (!string.IsNullOrEmpty(query.SeriesId)) { - var series = _libraryManager.GetItemById(request.SeriesId) as Series; - - if (series != null) + if (_libraryManager.GetItemById(query.SeriesId) is Series series) { presentationUniqueKey = GetUniqueSeriesKey(series); } @@ -49,16 +53,14 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request); + return GetResult(GetNextUpEpisodes(query, user, new[] { presentationUniqueKey }, options), query); } - var parentIdGuid = string.IsNullOrEmpty(request.ParentId) ? (Guid?)null : new Guid(request.ParentId); - BaseItem[] parents; - if (parentIdGuid.HasValue) + if (query.ParentId.HasValue) { - var parent = _libraryManager.GetItemById(parentIdGuid.Value); + var parent = _libraryManager.GetItemById(query.ParentId.Value); if (parent != null) { @@ -73,14 +75,14 @@ namespace Emby.Server.Implementations.TV { parents = _libraryManager.GetUserRootFolder().GetChildren(user, true) .Where(i => i is Folder) - .Where(i => !user.Configuration.LatestItemsExcludes.Contains(i.Id.ToString("N"))) + .Where(i => !user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes).Contains(i.Id)) .ToArray(); } - return GetNextUp(request, parents, dtoOptions); + return GetNextUp(query, parents, options); } - public QueryResult<BaseItem> GetNextUp(NextUpQuery request, BaseItem[] parentsFolders, DtoOptions dtoOptions) + public QueryResult<BaseItem> GetNextUp(NextUpQuery request, BaseItem[] parentsFolders, DtoOptions options) { var user = _userManager.GetUserById(request.UserId); @@ -93,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; @@ -104,7 +104,7 @@ namespace Emby.Server.Implementations.TV if (!string.IsNullOrEmpty(presentationUniqueKey)) { - return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request); + return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, options), request); } if (limit.HasValue) @@ -112,26 +112,23 @@ namespace Emby.Server.Implementations.TV limit = limit.Value + 10; } - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { typeof(Episode).Name }, - OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DatePlayed, SortOrder.Descending) }, - SeriesPresentationUniqueKey = presentationUniqueKey, - Limit = limit, - DtoOptions = new DtoOptions - { - Fields = new ItemFields[] + var items = _libraryManager + .GetItemList( + new InternalItemsQuery(user) { - ItemFields.SeriesPresentationUniqueKey - }, - EnableImages = false - }, - GroupBySeriesPresentationUniqueKey = true - - }, parentsFolders.ToList()).Cast<Episode>().Select(GetUniqueSeriesKey); + IncludeItemTypes = new[] { nameof(Episode) }, + OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.DatePlayed, SortOrder.Descending) }, + SeriesPresentationUniqueKey = presentationUniqueKey, + Limit = limit, + DtoOptions = new DtoOptions { Fields = new[] { ItemFields.SeriesPresentationUniqueKey }, EnableImages = false }, + GroupBySeriesPresentationUniqueKey = true + }, parentsFolders.ToList()) + .Cast<Episode>() + .Where(episode => !string.IsNullOrEmpty(episode.SeriesPresentationUniqueKey)) + .Select(GetUniqueSeriesKey); // Avoid implicitly captured closure - var episodes = GetNextUpEpisodes(request, user, items, dtoOptions); + var episodes = GetNextUpEpisodes(request, user, items, options); return GetResult(episodes, request); } @@ -144,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); @@ -154,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; @@ -191,38 +191,82 @@ namespace Emby.Server.Implementations.TV { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, - IncludeItemTypes = new[] { typeof(Episode).Name }, + IncludeItemTypes = new[] { nameof(Episode) }, OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Descending) }, IsPlayed = true, Limit = 1, 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, IsVirtualItem = false, ParentIndexNumberNotEquals = 0, - MinSortName = lastWatchedEpisode == null ? null : lastWatchedEpisode.SortName, + 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) @@ -253,6 +297,7 @@ namespace Emby.Server.Implementations.TV { items = items.Skip(query.StartIndex.Value); } + if (query.Limit.HasValue) { items = items.Take(query.Limit.Value); diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index bd86c6cdc..bf51c3968 100644 --- a/Emby.Server.Implementations/Udp/UdpServer.cs +++ b/Emby.Server.Implementations/Udp/UdpServer.cs @@ -1,293 +1,145 @@ using System; -using System.Collections.Generic; -using System.Linq; +using System.Net; +using System.Net.Sockets; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller; using MediaBrowser.Model.ApiClient; -using MediaBrowser.Model.Events; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Udp { /// <summary> - /// Provides a Udp Server + /// Provides a Udp Server. /// </summary> - public class UdpServer : IDisposable + public sealed class UdpServer : IDisposable { /// <summary> - /// The _logger + /// Address Override Configuration Key. /// </summary> - private readonly ILogger _logger; + public const string AddressOverrideConfigKey = "PublishedServerUrl"; - private bool _isDisposed; + /// <summary> + /// The _logger. + /// </summary> + private readonly ILogger _logger; + private readonly IServerApplicationHost _appHost; + private readonly IConfiguration _config; - private readonly List<Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, CancellationToken, Task>>> _responders = new List<Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, CancellationToken, Task>>>(); + private readonly byte[] _receiveBuffer = new byte[8192]; - private readonly IServerApplicationHost _appHost; - private readonly IJsonSerializer _json; + private Socket _udpSocket; + private IPEndPoint _endpoint; + private bool _disposed = false; /// <summary> /// Initializes a new instance of the <see cref="UdpServer" /> class. /// </summary> - public UdpServer(ILogger logger, IServerApplicationHost appHost, IJsonSerializer json, ISocketFactory socketFactory) + /// <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; - _json = json; - _socketFactory = socketFactory; + _config = configuration; - AddMessageResponder("who is JellyfinServer?", true, RespondToV2Message); - AddMessageResponder("who is EmbyServer?", true, RespondToV2Message); - AddMessageResponder("who is MediaBrowserServer_v2?", false, RespondToV2Message); - } + _endpoint = new IPEndPoint(IPAddress.Any, port); - private void AddMessageResponder(string message, bool isSubstring, Func<string, IpEndPointInfo, Encoding, CancellationToken, Task> responder) - { - _responders.Add(new Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, CancellationToken, Task>>(message, isSubstring, responder)); + _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); } - /// <summary> - /// Raises the <see cref="E:MessageReceived" /> event. - /// </summary> - private async void OnMessageReceived(GenericEventArgs<SocketReceiveResult> e) + private async Task RespondToV2Message(string messageText, EndPoint endpoint, CancellationToken cancellationToken) { - var message = e.Argument; - - var encoding = Encoding.UTF8; - var responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding); - - if (responder == null) - { - encoding = Encoding.Unicode; - responder = GetResponder(message.Buffer, message.ReceivedBytes, encoding); - } - - if (responder != null) + string? localUrl = _config[AddressOverrideConfigKey]; + if (string.IsNullOrEmpty(localUrl)) { - var cancellationToken = CancellationToken.None; - - try - { - await responder.Item2.Item3(responder.Item1, message.RemoteEndPoint, encoding, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in OnMessageReceived"); - } + localUrl = _appHost.GetSmartApiUrl(((IPEndPoint)endpoint).Address); } - } - - private Tuple<string, Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, CancellationToken, Task>>> GetResponder(byte[] buffer, int bytesReceived, Encoding encoding) - { - var text = encoding.GetString(buffer, 0, bytesReceived); - var responder = _responders.FirstOrDefault(i => - { - if (i.Item2) - { - return text.IndexOf(i.Item1, StringComparison.OrdinalIgnoreCase) != -1; - } - return string.Equals(i.Item1, text, StringComparison.OrdinalIgnoreCase); - }); - if (responder == null) + if (string.IsNullOrEmpty(localUrl)) { - return null; + _logger.LogWarning("Unable to respond to udp request because the local ip address could not be determined."); + return; } - return new Tuple<string, Tuple<string, bool, Func<string, IpEndPointInfo, Encoding, CancellationToken, Task>>>(text, responder); - } - private async Task RespondToV2Message(string messageText, IpEndPointInfo endpoint, Encoding encoding, CancellationToken cancellationToken) - { - var parts = messageText.Split('|'); - - var localUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false); + var response = new ServerDiscoveryInfo(localUrl, _appHost.SystemId, _appHost.FriendlyName); - if (!string.IsNullOrEmpty(localUrl)) + try { - var response = new ServerDiscoveryInfo - { - Address = localUrl, - Id = _appHost.SystemId, - Name = _appHost.FriendlyName - }; - - await SendAsync(encoding.GetBytes(_json.SerializeToString(response)), endpoint, cancellationToken).ConfigureAwait(false); - - if (parts.Length > 1) - { - _appHost.EnableLoopback(parts[1]); - } + 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> - /// The _udp client - /// </summary> - private ISocket _udpClient; - private readonly ISocketFactory _socketFactory; - - /// <summary> /// Starts the specified port. /// </summary> - /// <param name="port">The port.</param> - public void Start(int port) + /// <param name="cancellationToken">The cancellation token to cancel operation.</param> + public void Start(CancellationToken cancellationToken) { - _udpClient = _socketFactory.CreateUdpSocket(port); + _udpSocket.Bind(_endpoint); - Task.Run(() => BeginReceive()); + _ = Task.Run(async () => await BeginReceiveAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); } - private readonly byte[] _receiveBuffer = new byte[8192]; - - private void BeginReceive() + private async Task BeginReceiveAsync(CancellationToken cancellationToken) { - if (_isDisposed) - { - return; - } - - try + var infiniteTask = Task.Delay(-1, cancellationToken); + while (!cancellationToken.IsCancellationRequested) { - var result = _udpClient.BeginReceive(_receiveBuffer, 0, _receiveBuffer.Length, OnReceiveResult); - - if (result.CompletedSynchronously) + try { - OnReceiveResult(result); - } - } - catch (ObjectDisposedException) - { - //TODO Investigate and properly fix. - } - catch (Exception ex) - { - _logger.LogError(ex, "Error receiving udp message"); - } - } - - private void OnReceiveResult(IAsyncResult result) - { - if (_isDisposed) - { - return; - } - - try - { - var socketResult = _udpClient.EndReceive(result); - - OnMessageReceived(socketResult); - } - catch (ObjectDisposedException) - { - //TODO Investigate and properly fix. - } - catch (Exception ex) - { - _logger.LogError(ex, "Error receiving udp message"); - } - - BeginReceive(); - } + var task = _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint); + await Task.WhenAny(task, infiniteTask).ConfigureAwait(false); - /// <summary> - /// Called when [message received]. - /// </summary> - /// <param name="message">The message.</param> - private void OnMessageReceived(SocketReceiveResult message) - { - if (_isDisposed) - { - return; - } + if (!task.IsCompleted) + { + return; + } - if (message.RemoteEndPoint.Port == 0) - { - return; - } + var result = task.Result; - try - { - OnMessageReceived(new GenericEventArgs<SocketReceiveResult> + var text = Encoding.UTF8.GetString(_receiveBuffer, 0, result.ReceivedBytes); + if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase)) + { + await RespondToV2Message(text, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + } + } + catch (SocketException ex) { - Argument = message - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error handling UDP message"); - } - } - - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> - public void Dispose() - { - Dispose(true); - } - - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) - { - if (dispose) - { - _isDisposed = true; - - if (_udpClient != null) + _logger.LogError(ex, "Failed to receive data from socket"); + } + catch (OperationCanceledException) { - _udpClient.Dispose(); + // Don't throw } } } - public async Task SendAsync(byte[] bytes, IpEndPointInfo remoteEndPoint, CancellationToken cancellationToken) + /// <inheritdoc /> + public void Dispose() { - if (_isDisposed) + if (_disposed) { - throw new ObjectDisposedException(GetType().Name); - } - - if (bytes == null) - { - throw new ArgumentNullException(nameof(bytes)); - } - - if (remoteEndPoint == null) - { - throw new ArgumentNullException(nameof(remoteEndPoint)); + return; } - try - { - await _udpClient.SendToAsync(bytes, 0, bytes.Length, remoteEndPoint, cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Udp message sent to {remoteEndPoint}", remoteEndPoint); - } - catch (OperationCanceledException) - { + _udpSocket?.Dispose(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending message to {remoteEndPoint}", remoteEndPoint); - } + GC.SuppressFinalize(this); } } - } diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 301802b8a..4a022c5db 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -3,465 +3,347 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; +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 Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; -using MediaBrowser.Common.Progress; using MediaBrowser.Common.Updates; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Model.Cryptography; -using MediaBrowser.Model.Events; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Events.Updates; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Updates; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Updates { /// <summary> - /// Manages all install, uninstall and update operations (both plugins and system) + /// Manages all install, uninstall, and update operations for the system and individual plugins. /// </summary> public class InstallationManager : IInstallationManager { - public event EventHandler<InstallationEventArgs> PackageInstalling; - public event EventHandler<InstallationEventArgs> PackageInstallationCompleted; - public event EventHandler<InstallationFailedEventArgs> PackageInstallationFailed; - public event EventHandler<InstallationEventArgs> PackageInstallationCancelled; - /// <summary> - /// The current installations + /// The logger. /// </summary> - public List<Tuple<InstallationInfo, CancellationTokenSource>> CurrentInstallations { get; set; } + private readonly ILogger<InstallationManager> _logger; + private readonly IApplicationPaths _appPaths; + private readonly IEventManager _eventManager; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IServerConfigurationManager _config; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly IPluginManager _pluginManager; /// <summary> - /// The completed installations + /// Gets the application host. /// </summary> - private ConcurrentBag<InstallationInfo> CompletedInstallationsInternal { get; set; } - - public IEnumerable<InstallationInfo> CompletedInstallations => CompletedInstallationsInternal; + /// <value>The application host.</value> + private readonly IServerApplicationHost _applicationHost; + private readonly IZipClient _zipClient; + private readonly object _currentInstallationsLock = new object(); - #region PluginUninstalled Event /// <summary> - /// Occurs when [plugin uninstalled]. + /// The current installations. /// </summary> - public event EventHandler<GenericEventArgs<IPlugin>> PluginUninstalled; + private readonly List<(InstallationInfo info, CancellationTokenSource token)> _currentInstallations; /// <summary> - /// Called when [plugin uninstalled]. + /// The completed installations. /// </summary> - /// <param name="plugin">The plugin.</param> - private void OnPluginUninstalled(IPlugin plugin) - { - PluginUninstalled?.Invoke(this, new GenericEventArgs<IPlugin> { Argument = plugin }); - } - #endregion + private readonly ConcurrentBag<InstallationInfo> _completedInstallationsInternal; - #region PluginUpdated Event - /// <summary> - /// Occurs when [plugin updated]. - /// </summary> - public event EventHandler<GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>>> PluginUpdated; /// <summary> - /// Called when [plugin updated]. + /// Initializes a new instance of the <see cref="InstallationManager"/> class. /// </summary> - /// <param name="plugin">The plugin.</param> - /// <param name="newVersion">The new version.</param> - private void OnPluginUpdated(IPlugin plugin, PackageVersionInfo newVersion) - { - _logger.LogInformation("Plugin updated: {0} {1} {2}", newVersion.name, newVersion.versionStr ?? string.Empty, newVersion.classification); - - PluginUpdated?.Invoke(this, new GenericEventArgs<Tuple<IPlugin, PackageVersionInfo>> { Argument = new Tuple<IPlugin, PackageVersionInfo>(plugin, newVersion) }); - - _applicationHost.NotifyPendingRestart(); - } - #endregion - - #region PluginInstalled Event - /// <summary> - /// Occurs when [plugin updated]. - /// </summary> - public event EventHandler<GenericEventArgs<PackageVersionInfo>> PluginInstalled; - /// <summary> - /// Called when [plugin installed]. - /// </summary> - /// <param name="package">The package.</param> - private void OnPluginInstalled(PackageVersionInfo package) - { - _logger.LogInformation("New plugin installed: {0} {1} {2}", package.name, package.versionStr ?? string.Empty, package.classification); - - PluginInstalled?.Invoke(this, new GenericEventArgs<PackageVersionInfo> { Argument = package }); - - _applicationHost.NotifyPendingRestart(); - } - #endregion - - /// <summary> - /// The _logger - /// </summary> - private readonly ILogger _logger; - - private readonly IApplicationPaths _appPaths; - private readonly IHttpClient _httpClient; - private readonly IJsonSerializer _jsonSerializer; - private readonly IServerConfigurationManager _config; - private readonly IFileSystem _fileSystem; - - /// <summary> - /// Gets the application host. - /// </summary> - /// <value>The application host.</value> - private readonly IApplicationHost _applicationHost; - - private readonly ICryptoProvider _cryptographyProvider; - private readonly IZipClient _zipClient; - - // netframework or netcore - private readonly string _packageRuntime; - + /// <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( - ILoggerFactory loggerFactory, - IApplicationHost appHost, + ILogger<InstallationManager> logger, + IServerApplicationHost appHost, IApplicationPaths appPaths, - IHttpClient httpClient, - IJsonSerializer jsonSerializer, + IEventManager eventManager, + IHttpClientFactory httpClientFactory, IServerConfigurationManager config, - IFileSystem fileSystem, - ICryptoProvider cryptographyProvider, IZipClient zipClient, - string packageRuntime) + IPluginManager pluginManager) { - if (loggerFactory == null) - { - throw new ArgumentNullException(nameof(loggerFactory)); - } - - CurrentInstallations = new List<Tuple<InstallationInfo, CancellationTokenSource>>(); - CompletedInstallationsInternal = new ConcurrentBag<InstallationInfo>(); + _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; - _cryptographyProvider = cryptographyProvider; _zipClient = zipClient; - _packageRuntime = packageRuntime; - _logger = loggerFactory.CreateLogger(nameof(InstallationManager)); - } - - private static Version GetPackageVersion(PackageVersionInfo version) - { - return new Version(ValueOrDefault(version.versionStr, "0.0.0.1")); + _jsonSerializerOptions = JsonDefaults.Options; + _pluginManager = pluginManager; } - private static string ValueOrDefault(string str, string def) - { - return string.IsNullOrEmpty(str) ? def : str; - } + /// <inheritdoc /> + public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal; - /// <summary> - /// Gets all available packages. - /// </summary> - /// <returns>Task{List{PackageInfo}}.</returns> - public async Task<List<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken, - bool withRegistration = true, - string packageType = null, - Version applicationVersion = null) + /// <inheritdoc /> + public async Task<PackageInfo[]> GetPackages(string manifestName, string manifest, bool filterIncompatible, CancellationToken cancellationToken = default) { - var packages = await GetAvailablePackagesWithoutRegistrationInfo(cancellationToken).ConfigureAwait(false); - return FilterPackages(packages, packageType, applicationVersion); - } - - /// <summary> - /// Gets all available packages. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{List{PackageInfo}}.</returns> - public async Task<List<PackageInfo>> GetAvailablePackagesWithoutRegistrationInfo(CancellationToken cancellationToken) - { - using (var response = await _httpClient.SendAsync(new HttpRequestOptions - { - Url = "https://repo.jellyfin.org/releases/plugin/manifest.json", - CancellationToken = cancellationToken, - Progress = new SimpleProgress<double>(), - CacheLength = GetCacheLength() - }, "GET").ConfigureAwait(false)) + try { - using (var stream = response.Content) + PackageInfo[]? packages = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetFromJsonAsync<PackageInfo[]>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false); + + if (packages == null) { - return FilterPackages(await _jsonSerializer.DeserializeFromStreamAsync<PackageInfo[]>(stream).ConfigureAwait(false)); + return Array.Empty<PackageInfo>(); } - } - } - private PackageVersionClass GetSystemUpdateLevel() - { - return _applicationHost.SystemUpdateLevel; - } - - private static TimeSpan GetCacheLength() - { - return TimeSpan.FromMinutes(3); - } - - protected List<PackageInfo> FilterPackages(IEnumerable<PackageInfo> packages) - { - var list = new List<PackageInfo>(); - - foreach (var package in packages) - { - var versions = new List<PackageVersionInfo>(); - foreach (var version in package.versions) + 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) { - if (string.IsNullOrEmpty(version.sourceUrl)) - { - continue; - } - - if (string.IsNullOrEmpty(version.runtimes) || version.runtimes.IndexOf(_packageRuntime, StringComparison.OrdinalIgnoreCase) == -1) + for (int a = entry.Versions.Count - 1; a >= 0; a--) { - continue; + 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); } - - versions.Add(version); } - package.versions = versions - .OrderByDescending(GetPackageVersion) - .ToArray(); - - if (package.versions.Length == 0) - { - continue; - } - - list.Add(package); + return packages; + } + catch (IOException ex) + { + _logger.LogError(ex, "Cannot locate the plugin manifest {Manifest}", manifest); + return Array.Empty<PackageInfo>(); + } + catch (JsonException ex) + { + _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) + { + _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest); + return Array.Empty<PackageInfo>(); } - - // Remove packages with no versions - return list; } - protected List<PackageInfo> FilterPackages(IEnumerable<PackageInfo> packages, string packageType, Version applicationVersion) + /// <inheritdoc /> + public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default) { - var packagesList = FilterPackages(packages); - - var returnList = new List<PackageInfo>(); - - var filterOnPackageType = !string.IsNullOrEmpty(packageType); - - foreach (var p in packagesList) + var result = new List<PackageInfo>(); + foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories) { - if (filterOnPackageType && !string.Equals(p.type, packageType, StringComparison.OrdinalIgnoreCase)) + if (repository.Enabled && repository.Url != null) { - continue; - } - - // If an app version was supplied, filter the versions for each package to only include supported versions - if (applicationVersion != null) - { - p.versions = p.versions.Where(v => IsPackageVersionUpToDate(v, applicationVersion)).ToArray(); - } - - if (p.versions.Length == 0) - { - continue; + // 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); + } + } } - - returnList.Add(p); } - return returnList; + return result; } - /// <summary> - /// Determines whether [is package version up to date] [the specified package version info]. - /// </summary> - /// <param name="packageVersionInfo">The package version info.</param> - /// <param name="currentServerVersion">The current server version.</param> - /// <returns><c>true</c> if [is package version up to date] [the specified package version info]; otherwise, <c>false</c>.</returns> - private static bool IsPackageVersionUpToDate(PackageVersionInfo packageVersionInfo, Version currentServerVersion) + /// <inheritdoc /> + public IEnumerable<PackageInfo> FilterPackages( + IEnumerable<PackageInfo> availablePackages, + string? name = null, + Guid id = default, + Version? specificVersion = null) { - if (string.IsNullOrEmpty(packageVersionInfo.requiredVersionStr)) + if (name != null) { - return true; + availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } - return Version.TryParse(packageVersionInfo.requiredVersionStr, out var requiredVersion) && currentServerVersion >= requiredVersion; - } - - /// <summary> - /// Gets the package. - /// </summary> - /// <param name="name">The name.</param> - /// <param name="guid">The assembly guid</param> - /// <param name="classification">The classification.</param> - /// <param name="version">The version.</param> - /// <returns>Task{PackageVersionInfo}.</returns> - public async Task<PackageVersionInfo> GetPackage(string name, string guid, PackageVersionClass classification, Version version) - { - var packages = await GetAvailablePackages(CancellationToken.None, false).ConfigureAwait(false); - - var package = packages.FirstOrDefault(p => string.Equals(p.guid, guid ?? "none", StringComparison.OrdinalIgnoreCase)) - ?? packages.FirstOrDefault(p => p.name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (id != default) + { + availablePackages = availablePackages.Where(x => x.Id == id); + } - if (package == null) + if (specificVersion != null) { - return null; + availablePackages = availablePackages.Where(x => x.Versions.Any(y => y.VersionNumber.Equals(specificVersion))); } - return package.versions.FirstOrDefault(v => GetPackageVersion(v).Equals(version) && v.classification == classification); + return availablePackages; } - /// <summary> - /// Gets the latest compatible version. - /// </summary> - /// <param name="name">The name.</param> - /// <param name="guid">The assembly guid if this is a plug-in</param> - /// <param name="currentServerVersion">The current server version.</param> - /// <param name="classification">The classification.</param> - /// <returns>Task{PackageVersionInfo}.</returns> - public async Task<PackageVersionInfo> GetLatestCompatibleVersion(string name, string guid, Version currentServerVersion, PackageVersionClass classification = PackageVersionClass.Release) + /// <inheritdoc /> + public IEnumerable<InstallationInfo> GetCompatibleVersions( + IEnumerable<PackageInfo> availablePackages, + string? name = null, + Guid id = default, + Version? minVersion = null, + Version? specificVersion = null) { - var packages = await GetAvailablePackages(CancellationToken.None, false).ConfigureAwait(false); - - return GetLatestCompatibleVersion(packages, name, guid, currentServerVersion, classification); - } - - /// <summary> - /// Gets the latest compatible version. - /// </summary> - /// <param name="availablePackages">The available packages.</param> - /// <param name="name">The name.</param> - /// <param name="currentServerVersion">The current server version.</param> - /// <param name="classification">The classification.</param> - /// <returns>PackageVersionInfo.</returns> - public PackageVersionInfo GetLatestCompatibleVersion(IEnumerable<PackageInfo> availablePackages, string name, string guid, Version currentServerVersion, PackageVersionClass classification = PackageVersionClass.Release) - { - var package = availablePackages.FirstOrDefault(p => string.Equals(p.guid, guid ?? "none", StringComparison.OrdinalIgnoreCase)) - ?? availablePackages.FirstOrDefault(p => p.name.Equals(name, StringComparison.OrdinalIgnoreCase)); + var package = FilterPackages(availablePackages, name, id, specificVersion).FirstOrDefault(); + // Package not found in repository if (package == null) { - return null; + yield break; } - return package.versions - .OrderByDescending(GetPackageVersion) - .FirstOrDefault(v => v.classification <= classification && IsPackageVersionUpToDate(v, currentServerVersion)); - } - - /// <summary> - /// Gets the available plugin updates. - /// </summary> - /// <param name="applicationVersion">The current server version.</param> - /// <param name="withAutoUpdateEnabled">if set to <c>true</c> [with auto update enabled].</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{IEnumerable{PackageVersionInfo}}.</returns> - public async Task<IEnumerable<PackageVersionInfo>> GetAvailablePluginUpdates(Version applicationVersion, bool withAutoUpdateEnabled, CancellationToken cancellationToken) - { - var catalog = await GetAvailablePackagesWithoutRegistrationInfo(cancellationToken).ConfigureAwait(false); - - var systemUpdateLevel = GetSystemUpdateLevel(); + var appVer = _applicationHost.ApplicationVersion; + var availableVersions = package.Versions + .Where(x => string.IsNullOrEmpty(x.TargetAbi) || Version.Parse(x.TargetAbi) <= appVer); - // Figure out what needs to be installed - return _applicationHost.Plugins.Select(p => + if (specificVersion != null) + { + availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion)); + } + else if (minVersion != null) { - var latestPluginInfo = GetLatestCompatibleVersion(catalog, p.Name, p.Id.ToString(), applicationVersion, systemUpdateLevel); + availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion); + } - return latestPluginInfo != null && GetPackageVersion(latestPluginInfo) > p.Version ? latestPluginInfo : null; + foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber)) + { + yield return new InstallationInfo + { + Changelog = v.Changelog, + Id = package.Id, + Name = package.Name, + Version = v.VersionNumber, + SourceUrl = v.SourceUrl, + Checksum = v.Checksum, + PackageInfo = package + }; + } + } - }).Where(i => i != null) - .Where(p => !string.IsNullOrEmpty(p.sourceUrl) && !CompletedInstallations.Any(i => string.Equals(i.AssemblyGuid, p.guid, StringComparison.OrdinalIgnoreCase))); + /// <inheritdoc /> + public async Task<IEnumerable<InstallationInfo>> GetAvailablePluginUpdates(CancellationToken cancellationToken = default) + { + var catalog = await GetAvailablePackages(cancellationToken).ConfigureAwait(false); + return GetAvailablePluginUpdates(catalog); } - /// <summary> - /// Installs the package. - /// </summary> - /// <param name="package">The package.</param> - /// <param name="isPlugin">if set to <c>true</c> [is plugin].</param> - /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - /// <exception cref="ArgumentNullException">package</exception> - public async Task InstallPackage(PackageVersionInfo package, bool isPlugin, IProgress<double> progress, CancellationToken cancellationToken) + /// <inheritdoc /> + public async Task InstallPackage(InstallationInfo package, CancellationToken cancellationToken) { if (package == null) { throw new ArgumentNullException(nameof(package)); } - if (progress == null) - { - throw new ArgumentNullException(nameof(progress)); - } - - var installationInfo = new InstallationInfo - { - Id = Guid.NewGuid(), - Name = package.name, - AssemblyGuid = package.guid, - UpdateClass = package.classification, - Version = package.versionStr - }; - var innerCancellationTokenSource = new CancellationTokenSource(); - var tuple = new Tuple<InstallationInfo, CancellationTokenSource>(installationInfo, innerCancellationTokenSource); + var tuple = (package, innerCancellationTokenSource); // Add it to the in-progress list - lock (CurrentInstallations) + lock (_currentInstallationsLock) { - CurrentInstallations.Add(tuple); + _currentInstallations.Add(tuple); } - var innerProgress = new ActionableProgress<double>(); - - // Whenever the progress updates, update the outer progress object and InstallationInfo - innerProgress.RegisterAction(percent => - { - progress.Report(percent); + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token); + var linkedToken = linkedTokenSource.Token; - installationInfo.PercentComplete = percent; - }); - - var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token; - - var installationEventArgs = new InstallationEventArgs - { - InstallationInfo = installationInfo, - PackageVersionInfo = package - }; - - PackageInstalling?.Invoke(this, installationEventArgs); + await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false); try { - await InstallPackageInternal(package, isPlugin, innerProgress, linkedToken).ConfigureAwait(false); + var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false); - lock (CurrentInstallations) + lock (_currentInstallationsLock) { - CurrentInstallations.Remove(tuple); + _currentInstallations.Remove(tuple); } - CompletedInstallationsInternal.Add(installationInfo); + _completedInstallationsInternal.Add(package); + await _eventManager.PublishAsync(isUpdate + ? (GenericEventArgs<InstallationInfo>)new PluginUpdatedEventArgs(package) + : new PluginInstalledEventArgs(package)).ConfigureAwait(false); - PackageInstallationCompleted?.Invoke(this, installationEventArgs); + _applicationHost.NotifyPendingRestart(); } catch (OperationCanceledException) { - lock (CurrentInstallations) + lock (_currentInstallationsLock) { - CurrentInstallations.Remove(tuple); + _currentInstallations.Remove(tuple); } - _logger.LogInformation("Package installation cancelled: {0} {1}", package.name, package.versionStr); + _logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version); - PackageInstallationCancelled?.Invoke(this, installationEventArgs); + await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false); throw; } @@ -469,181 +351,229 @@ namespace Emby.Server.Implementations.Updates { _logger.LogError(ex, "Package installation failed"); - lock (CurrentInstallations) + lock (_currentInstallationsLock) { - CurrentInstallations.Remove(tuple); + _currentInstallations.Remove(tuple); } - PackageInstallationFailed?.Invoke(this, new InstallationFailedEventArgs + await _eventManager.PublishAsync(new InstallationFailedEventArgs { - InstallationInfo = installationInfo, + InstallationInfo = package, Exception = ex - }); + }).ConfigureAwait(false); throw; } finally { // Dispose the progress object and remove the installation from the in-progress list - tuple.Item2.Dispose(); + tuple.innerCancellationTokenSource.Dispose(); } } /// <summary> - /// Installs the package internal. + /// Uninstalls a plugin. /// </summary> - /// <param name="package">The package.</param> - /// <param name="isPlugin">if set to <c>true</c> [is plugin].</param> - /// <param name="progress">The progress.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - private async Task InstallPackageInternal(PackageVersionInfo package, bool isPlugin, IProgress<double> progress, CancellationToken cancellationToken) + /// <param name="plugin">The <see cref="LocalPlugin"/> to uninstall.</param> + public void UninstallPlugin(LocalPlugin plugin) { - IPlugin plugin = null; + if (plugin == null) + { + return; + } - if (isPlugin) + if (plugin.Instance?.CanUninstall == false) { - // Set last update time if we were installed before - plugin = _applicationHost.Plugins.FirstOrDefault(p => string.Equals(p.Id.ToString(), package.guid, StringComparison.OrdinalIgnoreCase)) - ?? _applicationHost.Plugins.FirstOrDefault(p => p.Name.Equals(package.name, StringComparison.OrdinalIgnoreCase)); + _logger.LogWarning("Attempt to delete non removable plugin {PluginName}, ignoring request", plugin.Name); + return; } - string targetPath = plugin == null ? null : plugin.AssemblyFilePath; + plugin.Instance?.OnUninstalling(); + + // Remove it the quick way for now + _pluginManager.RemovePlugin(plugin); + + _eventManager.Publish(new PluginUninstalledEventArgs(plugin.GetPluginInfo())); - // Do the install - await PerformPackageInstallation(progress, targetPath, package, cancellationToken).ConfigureAwait(false); + _applicationHost.NotifyPendingRestart(); + } - // Do plugin-specific processing - if (isPlugin) + /// <inheritdoc/> + public bool CancelInstallation(Guid id) + { + lock (_currentInstallationsLock) { - if (plugin == null) + var install = _currentInstallations.Find(x => x.info.Id == id); + if (install == default((InstallationInfo, CancellationTokenSource))) { - OnPluginInstalled(package); - } - else - { - OnPluginUpdated(plugin, package); + return false; } + + install.token.Cancel(); + _currentInstallations.Remove(install); + return true; } } - private async Task PerformPackageInstallation(IProgress<double> progress, string target, PackageVersionInfo package, CancellationToken cancellationToken) + /// <inheritdoc /> + public void Dispose() { - var extension = Path.GetExtension(package.targetFilename); - var isArchive = string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase); + Dispose(true); + GC.SuppressFinalize(this); + } - if (!isArchive) + /// <summary> + /// Releases unmanaged and optionally managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources or <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) { - _logger.LogError("Only zip packages are supported. {Filename} is not a zip archive.", package.targetFilename); - return; - } + lock (_currentInstallationsLock) + { + foreach (var (info, token) in _currentInstallations) + { + token.Dispose(); + } - if (target == null) - { - target = Path.Combine(_appPaths.PluginsPath, Path.GetFileNameWithoutExtension(package.targetFilename)); + _currentInstallations.Clear(); + } } + } - // Download to temporary file so that, if interrupted, it won't destroy the existing installation - var tempFile = await _httpClient.GetTempFile(new HttpRequestOptions - { - Url = package.sourceUrl, - CancellationToken = cancellationToken, - Progress = progress + /// <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; - }).ConfigureAwait(false); + 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++]); + } - cancellationToken.ThrowIfCancellationRequested(); + break; + } + } + else + { + source.Insert(s++, dest[d++]); + if (d >= dLength) + { + break; + } - // TODO: Validate with a checksum, *properly* + sLength++; + destVersion = dest[d].VersionNumber; + } + } + } - // Success - move it to the real target - try + private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog) + { + var plugins = _pluginManager.Plugins; + foreach (var plugin in plugins) { - using (var stream = File.OpenRead(tempFile)) + // Don't auto update when plugin marked not to, or when it's disabled. + if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled) { - _zipClient.ExtractAllFromZip(stream, target, true); + continue; } - } - catch (IOException ex) - { - _logger.LogError(ex, "Error attempting to extract {TempFile} to {TargetFile}", tempFile, target); - throw; - } - try - { - _fileSystem.DeleteFile(tempFile); - } - catch (IOException ex) - { - // Don't fail because of this - _logger.LogError(ex, "Error deleting temp file {TempFile}", tempFile); + 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; + } } } - /// <summary> - /// Uninstalls a plugin - /// </summary> - /// <param name="plugin">The plugin.</param> - /// <exception cref="ArgumentException"></exception> - public void UninstallPlugin(IPlugin plugin) + private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken cancellationToken) { - plugin.OnUninstalling(); + 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; + } - // Remove it the quick way for now - _applicationHost.RemovePlugin(plugin); + // Always override the passed-in target (which is a file) and figure it out again + string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name); - var path = plugin.AssemblyFilePath; - _logger.LogInformation("Deleting plugin file {0}", path); + 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); - // 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)); + // CA5351: Do Not Use Broken Cryptographic Algorithms +#pragma warning disable CA5351 + using var md5 = MD5.Create(); + cancellationToken.ThrowIfCancellationRequested(); - if (!string.IsNullOrWhiteSpace(file)) + var hash = Convert.ToHexString(md5.ComputeHash(stream)); + if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) { - path = file; + _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."); } - _fileSystem.DeleteFile(path); + // Version folder as they cannot be overwritten in Windows. + targetDir += "_" + package.Version; - var list = _config.Configuration.UninstalledPlugins.ToList(); - var filename = Path.GetFileName(path); - if (!list.Contains(filename, StringComparer.OrdinalIgnoreCase)) + if (Directory.Exists(targetDir)) { - list.Add(filename); - _config.Configuration.UninstalledPlugins = list.ToArray(); - _config.SaveConfiguration(); + 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. + } } - OnPluginUninstalled(plugin); - - _applicationHost.NotifyPendingRestart(); + stream.Position = 0; + _zipClient.ExtractAllFromZip(stream, targetDir, true); + await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); + _pluginManager.ImportPluginFrom(targetDir); } - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool dispose) + private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken) { - if (dispose) - { - lock (CurrentInstallations) - { - foreach (var tuple in CurrentInstallations) - { - tuple.Item2.Dispose(); - } + 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)); - CurrentInstallations.Clear(); - } - } - } + 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); - public void Dispose() - { - Dispose(true); + return plugin != null; } } } diff --git a/Emby.Server.Implementations/UserViews/FolderImageProvider.cs b/Emby.Server.Implementations/UserViews/FolderImageProvider.cs deleted file mode 100644 index c810004ab..000000000 --- a/Emby.Server.Implementations/UserViews/FolderImageProvider.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Collections.Generic; -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.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; - -namespace Emby.Server.Implementations.UserViews -{ - public abstract class BaseFolderImageProvider<T> : BaseDynamicImageProvider<T> - where T : Folder, new() - { - protected ILibraryManager _libraryManager; - - public BaseFolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor) - { - _libraryManager = libraryManager; - } - - protected override List<BaseItem> GetItemsWithImages(BaseItem item) - { - return _libraryManager.GetItemList(new InternalItemsQuery - { - Parent = item, - DtoOptions = new DtoOptions(true), - ImageTypes = new ImageType[] { ImageType.Primary }, - OrderBy = new System.ValueTuple<string, SortOrder>[] - { - new System.ValueTuple<string, SortOrder>(ItemSortBy.IsFolder, SortOrder.Ascending), - new System.ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) - }, - Limit = 1 - }); - } - - protected override string CreateImage(BaseItem item, List<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType, int imageIndex) - { - return CreateSingleImage(itemsWithImages, outputPathWithoutExtension, ImageType.Primary); - } - - protected override bool Supports(BaseItem item) - { - return item is T; - } - - protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image) - { - if (item is MusicAlbum) - { - return false; - } - - return base.HasChangedByDate(item, image); - } - } - - public class FolderImageProvider : BaseFolderImageProvider<Folder> - { - public FolderImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - - protected override bool Supports(BaseItem item) - { - if (item is PhotoAlbum || item is MusicAlbum) - { - return false; - } - - var folder = item as Folder; - if (folder != null) - { - if (folder.IsTopParent) - { - return false; - } - } - return true; - //return item.SourceType == SourceType.Library; - } - } - - public class MusicAlbumImageProvider : BaseFolderImageProvider<MusicAlbum> - { - public MusicAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - } - - public class PhotoAlbumImageProvider : BaseFolderImageProvider<PhotoAlbum> - { - public PhotoAlbumImageProvider(IFileSystem fileSystem, IProviderManager providerManager, IApplicationPaths applicationPaths, IImageProcessor imageProcessor, ILibraryManager libraryManager) - : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) - { - } - } -} diff --git a/Emby.Server.Implementations/Xml/XmlReaderSettingsFactory.cs b/Emby.Server.Implementations/Xml/XmlReaderSettingsFactory.cs deleted file mode 100644 index 308922e6d..000000000 --- a/Emby.Server.Implementations/Xml/XmlReaderSettingsFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Xml; -using MediaBrowser.Model.Xml; - -namespace Emby.Server.Implementations.Xml -{ - public class XmlReaderSettingsFactory : IXmlReaderSettingsFactory - { - public XmlReaderSettings Create(bool enableValidation) - { - var settings = new XmlReaderSettings(); - - if (!enableValidation) - { - settings.ValidationType = ValidationType.None; - } - - return settings; - } - } -} |
