diff options
81 files changed, 1935 insertions, 2122 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index e92a404d24..839bebb96a 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 + uses: github/codeql-action/init@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 + uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 + uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0 diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 17fe0400a0..97f1a33e76 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -25,7 +25,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 with: name: openapi-head retention-days: 14 @@ -59,7 +59,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 with: name: openapi-base retention-days: 14 @@ -78,12 +78,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 + uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 with: name: openapi-base path: openapi-base diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 0dacbc5c61..4b5db14aef 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@4d510cbed8a05af5aefea46c7fd6e05b95844c89 # 5.2.0 + uses: danielpalme/ReportGenerator-GitHub-Action@68f1963d9876d2ac78bfd1c41c395514b7318855 # 5.2.1 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 457f59e0f6..3be6331c8f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,6 +4,7 @@ - [97carmine](https://github.com/97carmine) - [Abbe98](https://github.com/Abbe98) - [agrenott](https://github.com/agrenott) + - [alltilla](https://github.com/alltilla) - [AndreCarvalho](https://github.com/AndreCarvalho) - [anthonylavado](https://github.com/anthonylavado) - [Artiume](https://github.com/Artiume) @@ -77,6 +78,7 @@ - [Marenz](https://github.com/Marenz) - [marius-luca-87](https://github.com/marius-luca-87) - [mark-monteiro](https://github.com/mark-monteiro) + - [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti) - [Matt07211](https://github.com/Matt07211) - [Maxr1998](https://github.com/Maxr1998) - [mcarlton00](https://github.com/mcarlton00) @@ -175,6 +177,7 @@ - [Chris-Codes-It](https://github.com/Chris-Codes-It) - [Pithaya](https://github.com/Pithaya) - [Çağrı Sakaoğlu](https://github.com/ilovepilav) + _ [Barasingha](https://github.com/MaVdbussche) - [Gauvino](https://github.com/Gauvino) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index 8b4813f566..4a236e5b64 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ </PropertyGroup> <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.--> <ItemGroup Label="Package Dependencies"> + <PackageVersion Include="AsyncKeyedLock" Version="6.3.4" /> <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" /> <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" /> <PackageVersion Include="AutoFixture" Version="4.18.1" /> @@ -47,7 +48,7 @@ <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.1" /> - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" /> <PackageVersion Include="MimeTypes" Version="2.4.0" /> <PackageVersion Include="Mono.Nat" Version="3.0.4" /> <PackageVersion Include="Moq" Version="4.18.4" /> diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 5870fed761..550c16b4c4 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -62,7 +62,6 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Resolvers; @@ -393,7 +392,7 @@ namespace Emby.Server.Implementations /// Runs the startup tasks. /// </summary> /// <returns><see cref="Task" />.</returns> - public async Task RunStartupTasksAsync() + public Task RunStartupTasksAsync() { Logger.LogInformation("Running startup tasks"); @@ -405,38 +404,10 @@ namespace Emby.Server.Implementations Resolve<IMediaEncoder>().SetFFmpegPath(); Logger.LogInformation("ServerId: {ServerId}", SystemId); - - var entryPoints = GetExports<IServerEntryPoint>(); - - 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"); CoreStartupHasCompleted = true; - 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) - { - foreach (var entryPoint in entryPoints) - { - if (isBeforeStartup != (entryPoint is IRunBeforeStartup)) - { - continue; - } - - Logger.LogDebug("Starting entry point {Type}", entryPoint.GetType()); - - yield return entryPoint.RunAsync(); - } + return Task.CompletedTask; } /// <inheritdoc/> @@ -695,8 +666,6 @@ namespace Emby.Server.Implementations GetExports<IMetadataSaver>(), GetExports<IExternalId>()); - Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<IListingsProvider>()); - Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); } diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 83e7b230df..4c668379c8 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -13,19 +13,19 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints; /// <summary> -/// A <see cref="IServerEntryPoint"/> that notifies users when libraries are updated. +/// A <see cref="IHostedService"/> responsible for notifying users when libraries are updated. /// </summary> -public sealed class LibraryChangedNotifier : IServerEntryPoint +public sealed class LibraryChangedNotifier : IHostedService, IDisposable { private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; @@ -70,7 +70,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint } /// <inheritdoc /> - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { _libraryManager.ItemAdded += OnLibraryItemAdded; _libraryManager.ItemUpdated += OnLibraryItemUpdated; @@ -83,6 +83,20 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint return Task.CompletedTask; } + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) + { + _libraryManager.ItemAdded -= OnLibraryItemAdded; + _libraryManager.ItemUpdated -= OnLibraryItemUpdated; + _libraryManager.ItemRemoved -= OnLibraryItemRemoved; + + _providerManager.RefreshCompleted -= OnProviderRefreshCompleted; + _providerManager.RefreshStarted -= OnProviderRefreshStarted; + _providerManager.RefreshProgress -= OnProviderRefreshProgress; + + return Task.CompletedTask; + } + private void OnProviderRefreshProgress(object? sender, GenericEventArgs<Tuple<BaseItem, double>> e) { var item = e.Argument.Item1; @@ -137,9 +151,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint } private void OnProviderRefreshStarted(object? sender, GenericEventArgs<BaseItem> e) - { - OnProviderRefreshProgress(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 OnProviderRefreshCompleted(object? sender, GenericEventArgs<BaseItem> e) { @@ -342,7 +354,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint return item.SourceType == SourceType.Library; } - private IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren) + private static IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren) { var list = new List<string>(); @@ -363,7 +375,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint return list.Distinct(StringComparer.Ordinal); } - private IEnumerable<T> TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false) + private T[] TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false) where T : BaseItem { // If the physical root changed, return the user root @@ -384,18 +396,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint /// <inheritdoc /> public void Dispose() { - _libraryManager.ItemAdded -= OnLibraryItemAdded; - _libraryManager.ItemUpdated -= OnLibraryItemUpdated; - _libraryManager.ItemRemoved -= OnLibraryItemRemoved; - - _providerManager.RefreshCompleted -= OnProviderRefreshCompleted; - _providerManager.RefreshStarted -= OnProviderRefreshStarted; - _providerManager.RefreshProgress -= OnProviderRefreshProgress; - - if (_libraryUpdateTimer is not null) - { - _libraryUpdateTimer.Dispose(); - _libraryUpdateTimer = null; - } + _libraryUpdateTimer?.Dispose(); + _libraryUpdateTimer = null; } } diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index d32759017d..957ad9c01b 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -8,14 +6,17 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; namespace Emby.Server.Implementations.EntryPoints { - public sealed class UserDataChangeNotifier : IServerEntryPoint + /// <summary> + /// <see cref="IHostedService"/> responsible for notifying users when associated item data is updated. + /// </summary> + public sealed class UserDataChangeNotifier : IHostedService, IDisposable { private const int UpdateDuration = 500; @@ -23,25 +24,43 @@ namespace Emby.Server.Implementations.EntryPoints private readonly IUserDataManager _userDataManager; private readonly IUserManager _userManager; - private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new Dictionary<Guid, List<BaseItem>>(); + private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new(); + private readonly object _syncLock = new(); - private readonly object _syncLock = new object(); private Timer? _updateTimer; - public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, IUserManager userManager) + /// <summary> + /// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class. + /// </summary> + /// <param name="userDataManager">The <see cref="IUserDataManager"/>.</param> + /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param> + /// <param name="userManager">The <see cref="IUserManager"/>.</param> + public UserDataChangeNotifier( + IUserDataManager userDataManager, + ISessionManager sessionManager, + IUserManager userManager) { _userDataManager = userDataManager; _sessionManager = sessionManager; _userManager = userManager; } - public Task RunAsync() + /// <inheritdoc /> + public Task StartAsync(CancellationToken cancellationToken) { _userDataManager.UserDataSaved += OnUserDataManagerUserDataSaved; return Task.CompletedTask; } + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= OnUserDataManagerUserDataSaved; + + return Task.CompletedTask; + } + private void OnUserDataManagerUserDataSaved(object? sender, UserDataSaveEventArgs e) { if (e.SaveReason == UserDataSaveReason.PlaybackProgress) @@ -103,55 +122,40 @@ namespace Emby.Server.Implementations.EntryPoints } } - await SendNotifications(changes, CancellationToken.None).ConfigureAwait(false); - } - - private async Task SendNotifications(List<KeyValuePair<Guid, List<BaseItem>>> changes, CancellationToken cancellationToken) - { - foreach ((var key, var value) in changes) + foreach (var (userId, changedItems) in changes) { - await SendNotifications(key, value, cancellationToken).ConfigureAwait(false); + await _sessionManager.SendMessageToUserSessions( + [userId], + SessionMessageType.UserDataChanged, + () => GetUserDataChangeInfo(userId, changedItems), + default).ConfigureAwait(false); } } - private Task SendNotifications(Guid userId, List<BaseItem> changedItems, CancellationToken cancellationToken) - { - return _sessionManager.SendMessageToUserSessions(new List<Guid> { userId }, SessionMessageType.UserDataChanged, () => GetUserDataChangeInfo(userId, changedItems), cancellationToken); - } - private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems) { var user = _userManager.GetUserById(userId); - var dtoList = changedItems - .DistinctBy(x => x.Id) - .Select(i => - { - var dto = _userDataManager.GetUserDataDto(i, user); - dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture); - return dto; - }) - .ToArray(); - - var userIdString = userId.ToString("N", CultureInfo.InvariantCulture); - return new UserDataChangeInfo { - UserId = userIdString, - - UserDataList = dtoList + UserId = userId.ToString("N", CultureInfo.InvariantCulture), + UserDataList = changedItems + .DistinctBy(x => x.Id) + .Select(i => + { + var dto = _userDataManager.GetUserDataDto(i, user); + dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture); + return dto; + }) + .ToArray() }; } + /// <inheritdoc /> public void Dispose() { - if (_updateTimer is not null) - { - _updateTimer.Dispose(); - _updateTimer = null; - } - - _userDataManager.UserDataSaved -= OnUserDataManagerUserDataSaved; + _updateTimer?.Dispose(); + _updateTimer = null; } } } diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index dde38906f3..31617d1a5f 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -11,11 +9,13 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.IO { - public class LibraryMonitor : ILibraryMonitor + /// <inheritdoc cref="ILibraryMonitor" /> + public sealed class LibraryMonitor : ILibraryMonitor, IDisposable { private readonly ILogger<LibraryMonitor> _logger; private readonly ILibraryManager _libraryManager; @@ -25,19 +25,19 @@ namespace Emby.Server.Implementations.IO /// <summary> /// The file system watchers. /// </summary> - private readonly ConcurrentDictionary<string, FileSystemWatcher> _fileSystemWatchers = new ConcurrentDictionary<string, FileSystemWatcher>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, FileSystemWatcher> _fileSystemWatchers = new(StringComparer.OrdinalIgnoreCase); /// <summary> /// The affected paths. /// </summary> - private readonly List<FileRefresher> _activeRefreshers = new List<FileRefresher>(); + private readonly List<FileRefresher> _activeRefreshers = []; /// <summary> /// 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 readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); - private bool _disposed = false; + private bool _disposed; /// <summary> /// Initializes a new instance of the <see cref="LibraryMonitor" /> class. @@ -46,34 +46,31 @@ namespace Emby.Server.Implementations.IO /// <param name="libraryManager">The library manager.</param> /// <param name="configurationManager">The configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> + /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> public LibraryMonitor( ILogger<LibraryMonitor> logger, ILibraryManager libraryManager, IServerConfigurationManager configurationManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IHostApplicationLifetime appLifetime) { _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. - /// </summary> - /// <param name="path">The path.</param> - private void TemporarilyIgnore(string path) - { - _tempIgnoredPaths[path] = path; + appLifetime.ApplicationStarted.Register(Start); } + /// <inheritdoc /> public void ReportFileSystemChangeBeginning(string path) { ArgumentException.ThrowIfNullOrEmpty(path); - TemporarilyIgnore(path); + _tempIgnoredPaths[path] = path; } + /// <inheritdoc /> public async void ReportFileSystemChangeComplete(string path, bool refreshPath) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -107,14 +104,10 @@ namespace Emby.Server.Implementations.IO var options = _libraryManager.GetLibraryOptions(item); - if (options is not null) - { - return options.EnableRealtimeMonitor; - } - - return false; + return options is not null && options.EnableRealtimeMonitor; } + /// <inheritdoc /> public void Start() { _libraryManager.ItemAdded += OnLibraryManagerItemAdded; @@ -306,21 +299,12 @@ namespace Emby.Server.Implementations.IO { if (removeFromList) { - RemoveWatcherFromList(watcher); + _fileSystemWatchers.TryRemove(watcher.Path, out _); } } } /// <summary> - /// Removes the watcher from list. - /// </summary> - /// <param name="watcher">The watcher.</param> - private void RemoveWatcherFromList(FileSystemWatcher watcher) - { - _fileSystemWatchers.TryRemove(watcher.Path, out _); - } - - /// <summary> /// Handles the Error event of the watcher control. /// </summary> /// <param name="sender">The source of the event.</param> @@ -352,6 +336,7 @@ namespace Emby.Server.Implementations.IO } } + /// <inheritdoc /> public void ReportFileSystemChanged(string path) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -479,31 +464,15 @@ namespace Emby.Server.Implementations.IO } } - /// <summary> - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// </summary> + /// <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) - { - Stop(); - } - + Stop(); _disposed = true; } } diff --git a/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs b/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs deleted file mode 100644 index c51cf05459..0000000000 --- a/Emby.Server.Implementations/IO/LibraryMonitorStartup.cs +++ /dev/null @@ -1,35 +0,0 @@ -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/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 8ae913dad8..7998ce34a7 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -22,7 +22,6 @@ 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; @@ -1022,7 +1021,7 @@ namespace Emby.Server.Implementations.Library // Start by just validating the children of the root, but go no further await RootFolder.ValidateChildren( - new SimpleProgress<double>(), + new Progress<double>(), new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: false, cancellationToken).ConfigureAwait(false); @@ -1030,7 +1029,7 @@ namespace Emby.Server.Implementations.Library await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false); await GetUserRootFolder().ValidateChildren( - new SimpleProgress<double>(), + new Progress<double>(), new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: false, cancellationToken).ConfigureAwait(false); @@ -1048,18 +1047,14 @@ namespace Emby.Server.Implementations.Library await ValidateTopLibraryFolders(cancellationToken).ConfigureAwait(false); - var innerProgress = new ActionableProgress<double>(); - - innerProgress.RegisterAction(pct => progress.Report(pct * 0.96)); + var innerProgress = new Progress<double>(pct => progress.Report(pct * 0.96)); // Validate the entire media library await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken).ConfigureAwait(false); progress.Report(96); - innerProgress = new ActionableProgress<double>(); - - innerProgress.RegisterAction(pct => progress.Report(96 + (pct * .04))); + innerProgress = new Progress<double>(pct => progress.Report(96 + (pct * .04))); await RunPostScanTasks(innerProgress, cancellationToken).ConfigureAwait(false); @@ -1081,12 +1076,10 @@ namespace Emby.Server.Implementations.Library foreach (var task in tasks) { - var innerProgress = new ActionableProgress<double>(); - // Prevent access to modified closure var currentNumComplete = numComplete; - innerProgress.RegisterAction(pct => + var innerProgress = new Progress<double>(pct => { double innerPercent = pct; innerPercent /= 100; @@ -2954,7 +2947,7 @@ namespace Emby.Server.Implementations.Library Task.Run(() => { // No need to start if scanning the library because it will handle it - ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None); + ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); }); } diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index c38f1af912..18ada6aeb5 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -52,7 +53,7 @@ namespace Emby.Server.Implementations.Library private readonly IDirectoryService _directoryService; private readonly ConcurrentDictionary<string, ILiveStream> _openStreams = new ConcurrentDictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase); - private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _liveStreamLocker = new(1); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private IMediaSourceProvider[] _providers; @@ -468,12 +469,10 @@ namespace Emby.Server.Implementations.Library public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) { - await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - MediaSourceInfo mediaSource; ILiveStream liveStream; - try + using (await _liveStreamLocker.LockAsync(cancellationToken).ConfigureAwait(false)) { var (provider, keyId) = GetProvider(request.OpenToken); @@ -493,10 +492,6 @@ namespace Emby.Server.Implementations.Library _openStreams[mediaSource.LiveStreamId] = liveStream; } - finally - { - _liveStreamSemaphore.Release(); - } try { @@ -837,9 +832,7 @@ namespace Emby.Server.Implementations.Library { ArgumentException.ThrowIfNullOrEmpty(id); - await _liveStreamSemaphore.WaitAsync().ConfigureAwait(false); - - try + using (await _liveStreamLocker.LockAsync().ConfigureAwait(false)) { if (_openStreams.TryGetValue(id, out ILiveStream liveStream)) { @@ -858,10 +851,6 @@ namespace Emby.Server.Implementations.Library } } } - finally - { - _liveStreamSemaphore.Release(); - } } private (IMediaSourceProvider MediaSourceProvider, string KeyId) GetProvider(string key) @@ -898,7 +887,7 @@ namespace Emby.Server.Implementations.Library CloseLiveStream(key).GetAwaiter().GetResult(); } - _liveStreamSemaphore.Dispose(); + _liveStreamLocker.Dispose(); } } } diff --git a/Emby.Server.Implementations/Localization/Core/ky.json b/Emby.Server.Implementations/Localization/Core/ky.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/ky.json @@ -0,0 +1 @@ +{} diff --git a/Emby.Server.Implementations/Localization/Core/or.json b/Emby.Server.Implementations/Localization/Core/or.json index 0e9d81ee87..8251c12907 100644 --- a/Emby.Server.Implementations/Localization/Core/or.json +++ b/Emby.Server.Implementations/Localization/Core/or.json @@ -1,4 +1,12 @@ { "External": "ବହିଃସ୍ଥ", - "Genres": "ଧରଣ" + "Genres": "ଧରଣ", + "Albums": "ଆଲବମଗୁଡ଼ିକ", + "Artists": "କଳାକାରଗୁଡ଼ିକ", + "Application": "ଆପ୍ଲିକେସନ", + "Books": "ବହିଗୁଡ଼ିକ", + "Channels": "ଚ୍ୟାନେଲଗୁଡ଼ିକ", + "ChapterNameValue": "ବିଭାଗ {0}", + "Collections": "ସଂଗ୍ରହଗୁଡ଼ିକ", + "Folders": "ଫୋଲ୍ଡରଗୁଡ଼ିକ" } diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index 1af2c96d2f..efb6436ae9 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -14,7 +14,6 @@ using Jellyfin.Data.Events; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -371,7 +370,7 @@ namespace Emby.Server.Implementations.ScheduledTasks throw new InvalidOperationException("Cannot execute a Task that is already running"); } - var progress = new SimpleProgress<double>(); + var progress = new Progress<double>(); CurrentCancellationTokenSource = new CancellationTokenSource(); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index dda1e9d561..590cdc33f0 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -294,9 +294,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (!System.IO.File.Exists(playlistPath)) { - var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { if (!System.IO.File.Exists(playlistPath)) { @@ -326,10 +324,6 @@ public class DynamicHlsController : BaseJellyfinApiController } } } - finally - { - transcodingLock.Release(); - } } job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); @@ -1442,95 +1436,80 @@ public class DynamicHlsController : BaseJellyfinApiController return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - var released = false; - var startTranscoding = false; - - try + using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { + var startTranscoding = false; if (System.IO.File.Exists(segmentPath)) { job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - transcodingLock.Release(); - released = true; _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - else - { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (segmentId == -1) - { - _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); - startTranscoding = true; - segmentId = 0; - } - else if (currentTranscodingIndex is null) - { - _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); - startTranscoding = true; - } - else if (segmentId < currentTranscodingIndex.Value) - { - _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); - startTranscoding = true; - } - else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) - { - _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); - startTranscoding = true; - } - if (startTranscoding) - { - // If the playlist doesn't already exist, startup ffmpeg - try - { - await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) - .ConfigureAwait(false); + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } + if (segmentId == -1) + { + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; + } + else if (currentTranscodingIndex is null) + { + _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); + startTranscoding = true; + } + else if (segmentId < currentTranscodingIndex.Value) + { + _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); + startTranscoding = true; + } + else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) + { + _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); + startTranscoding = true; + } - streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + if (startTranscoding) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + .ConfigureAwait(false); - state.WaitForPath = segmentPath; - job = await _transcodeManager.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state, false, segmentId), - Request.HttpContext.User.GetUserId(), - TranscodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } - catch + if (currentTranscodingIndex.HasValue) { - state.Dispose(); - throw; + DeleteLastFile(playlistPath, segmentExtension, 0); } - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + + state.WaitForPath = segmentPath; + job = await _transcodeManager.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, false, segmentId), + Request.HttpContext.User.GetUserId(), + TranscodingJobType, + cancellationTokenSource).ConfigureAwait(false); } - else + catch { - job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job?.TranscodingThrottler is not null) - { - await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); - } + state.Dispose(); + throw; } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); } - } - finally - { - if (!released) + else { - transcodingLock.Release(); + job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job?.TranscodingThrottler is not null) + { + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); + } } } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index a0bbc961f0..e357588d1d 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -17,7 +16,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -313,7 +311,7 @@ public class LibraryController : BaseJellyfinApiController { try { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index d483ca4d2b..23c430f859 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -6,11 +6,9 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; using MediaBrowser.Common.Api; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -180,7 +178,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } else { @@ -224,7 +222,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } else { @@ -293,7 +291,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } else { diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 1b2f5750f6..da68c72c99 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -43,6 +43,7 @@ namespace Jellyfin.Api.Controllers; public class LiveTvController : BaseJellyfinApiController { private readonly ILiveTvManager _liveTvManager; + private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; @@ -56,6 +57,7 @@ public class LiveTvController : BaseJellyfinApiController /// Initializes a new instance of the <see cref="LiveTvController"/> class. /// </summary> /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> + /// <param name="guideManager">Instance of the <see cref="IGuideManager"/> interface.</param> /// <param name="tunerHostManager">Instance of the <see cref="ITunerHostManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> @@ -66,6 +68,7 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param> public LiveTvController( ILiveTvManager liveTvManager, + IGuideManager guideManager, ITunerHostManager tunerHostManager, IUserManager userManager, IHttpClientFactory httpClientFactory, @@ -76,6 +79,7 @@ public class LiveTvController : BaseJellyfinApiController ITranscodeManager transcodeManager) { _liveTvManager = liveTvManager; + _guideManager = guideManager; _tunerHostManager = tunerHostManager; _userManager = userManager; _httpClientFactory = httpClientFactory; @@ -941,9 +945,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<GuideInfo> GetGuideInfo() - { - return _liveTvManager.GetGuideInfo(); - } + => _guideManager.GetGuideInfo(); /// <summary> /// Adds a tuner host. diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 3d4df03869..6c5ce47158 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -188,16 +188,24 @@ public class SystemController : BaseJellyfinApiController /// <param name="name">The name of the log file to get.</param> /// <response code="200">Log file retrieved.</response> /// <response code="403">User does not have permission to get log files.</response> + /// <response code="404">Could not find a log file with the name.</response> /// <returns>The log file.</returns> [HttpGet("Logs/Log")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesFile(MediaTypeNames.Text.Plain)] public ActionResult GetLogFile([FromQuery, Required] string name) { - var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var file = _fileSystem + .GetFiles(_appPaths.LogDirectoryPath) + .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + + if (file is null) + { + return NotFound("Log file not found."); + } // For older files, assume fully static var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index fa81fc284d..b0c17c8356 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -325,6 +325,7 @@ public class DynamicHlsHelper if (state.VideoStream is not null && state.VideoStream.VideoRange != VideoRange.Unknown) { var videoRange = state.VideoStream.VideoRange; + var videoRangeType = state.VideoStream.VideoRangeType; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { if (videoRange == VideoRange.SDR) @@ -334,7 +335,14 @@ public class DynamicHlsHelper if (videoRange == VideoRange.HDR) { - builder.Append(",VIDEO-RANGE=PQ"); + if (videoRangeType == VideoRangeType.HLG) + { + builder.Append(",VIDEO-RANGE=HLG"); + } + else + { + builder.Append(",VIDEO-RANGE=PQ"); + } } } else diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 5385979d4a..cb178a61d8 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -93,9 +93,7 @@ public static class FileStreamResponseHelpers return new OkResult(); } - var transcodingLock = transcodeManager.GetTranscodingLock(outputPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try + using (await transcodeManager.LockAsync(outputPath, cancellationTokenSource.Token).ConfigureAwait(false)) { TranscodingJob? job; if (!File.Exists(outputPath)) @@ -117,9 +115,5 @@ public static class FileStreamResponseHelpers var stream = new ProgressiveFileStream(outputPath, job, transcodeManager); return new FileStreamResult(stream, contentType); } - finally - { - transcodingLock.Release(); - } } } diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs index acd3f29e34..12ce19368b 100644 --- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json.Converters; @@ -50,6 +51,18 @@ public class ClientCapabilitiesDto /// </summary> public string? IconUrl { get; set; } +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsContentUploading { get; set; } + + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsSync { get; set; } +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + /// <summary> /// Convert the dto to the full <see cref="ClientCapabilities"/> model. /// </summary> diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 0ed1578c70..7c4155bfc6 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -26,6 +26,7 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="EFCoreSecondLevelCacheInterceptor" /> <PackageReference Include="System.Linq.Async" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" /> diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index b960feb7f3..f6854157a6 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; @@ -37,7 +38,7 @@ public class TrickplayManager : ITrickplayManager private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IApplicationPaths _appPaths; - private static readonly SemaphoreSlim _resourcePool = new(1, 1); + private static readonly AsyncNonKeyedLocker _resourcePool = new(1); private static readonly string[] _trickplayImgExtensions = { ".jpg" }; /// <summary> @@ -107,93 +108,92 @@ public class TrickplayManager : ITrickplayManager var imgTempDir = string.Empty; var outputDir = GetTrickplayDirectory(video, width); - await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { - if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width)) - { - _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); - return; - } - - // Extract images - // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. - var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); - - if (mediaSource is null) + try { - _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); - return; - } + if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width)) + { + _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id); + return; + } - var mediaPath = mediaSource.Path; - var mediaStream = mediaSource.VideoStream; - var container = mediaSource.Container; + // Extract images + // Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay. + var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id)); - _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id); - imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( - mediaPath, - container, - mediaSource, - mediaStream, - width, - TimeSpan.FromMilliseconds(options.Interval), - options.EnableHwAcceleration, - options.ProcessThreads, - options.Qscale, - options.ProcessPriority, - _encodingHelper, - cancellationToken).ConfigureAwait(false); + if (mediaSource is null) + { + _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); + return; + } - if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir)) - { - throw new InvalidOperationException("Null or invalid directory from media encoder."); - } + var mediaPath = mediaSource.Path; + var mediaStream = mediaSource.VideoStream; + var container = mediaSource.Container; + + _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id); + imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated( + mediaPath, + container, + mediaSource, + mediaStream, + width, + TimeSpan.FromMilliseconds(options.Interval), + options.EnableHwAcceleration, + options.ProcessThreads, + options.Qscale, + options.ProcessPriority, + _encodingHelper, + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir)) + { + throw new InvalidOperationException("Null or invalid directory from media encoder."); + } - var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false) - .Select(i => i.FullName) - .OrderBy(i => i) - .ToList(); + var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false) + .Select(i => i.FullName) + .OrderBy(i => i) + .ToList(); - // Create tiles - var trickplayInfo = CreateTiles(images, width, options, outputDir); + // Create tiles + var trickplayInfo = CreateTiles(images, width, options, outputDir); - // Save tiles info - try - { - if (trickplayInfo is not null) + // Save tiles info + try { - trickplayInfo.ItemId = video.Id; - await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); + if (trickplayInfo is not null) + { + trickplayInfo.ItemId = video.Id; + await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); - _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + } + else + { + throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + } } - else + catch (Exception ex) { - throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + _logger.LogError(ex, "Error while saving trickplay tiles info."); + + // Make sure no files stay in metadata folders on failure + // if tiles info wasn't saved. + Directory.Delete(outputDir, true); } } catch (Exception ex) { - _logger.LogError(ex, "Error while saving trickplay tiles info."); - - // Make sure no files stay in metadata folders on failure - // if tiles info wasn't saved. - Directory.Delete(outputDir, true); + _logger.LogError(ex, "Error creating trickplay images."); } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating trickplay images."); - } - finally - { - _resourcePool.Release(); - - if (!string.IsNullOrEmpty(imgTempDir)) + finally { - Directory.Delete(imgTempDir, true); + if (!string.IsNullOrEmpty(imgTempDir)) + { + Directory.Delete(imgTempDir, true); + } } } } diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs deleted file mode 100644 index a471ea1d50..0000000000 --- a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs +++ /dev/null @@ -1,64 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using Jellyfin.Data.Queries; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Session; - -namespace Jellyfin.Server.Implementations.Users -{ - public sealed class DeviceAccessEntryPoint : IServerEntryPoint - { - private readonly IUserManager _userManager; - private readonly IDeviceManager _deviceManager; - private readonly ISessionManager _sessionManager; - - public DeviceAccessEntryPoint(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager) - { - _userManager = userManager; - _deviceManager = deviceManager; - _sessionManager = sessionManager; - } - - public Task RunAsync() - { - _userManager.OnUserUpdated += OnUserUpdated; - - return Task.CompletedTask; - } - - public void Dispose() - { - } - - private async void OnUserUpdated(object? sender, GenericEventArgs<User> e) - { - var user = e.Argument; - if (!user.HasPermission(PermissionKind.EnableAllDevices)) - { - await UpdateDeviceAccess(user).ConfigureAwait(false); - } - } - - private async Task UpdateDeviceAccess(User user) - { - var existing = (await _deviceManager.GetDevices(new DeviceQuery - { - UserId = user.Id - }).ConfigureAwait(false)).Items; - - foreach (var device in existing) - { - if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId)) - { - await _sessionManager.Logout(device).ConfigureAwait(false); - } - } - } - } -} diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs new file mode 100644 index 0000000000..e40b541a35 --- /dev/null +++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs @@ -0,0 +1,76 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using Jellyfin.Data.Queries; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.Server.Implementations.Users; + +/// <summary> +/// <see cref="IHostedService"/> responsible for managing user device permissions. +/// </summary> +public sealed class DeviceAccessHost : IHostedService +{ + private readonly IUserManager _userManager; + private readonly IDeviceManager _deviceManager; + private readonly ISessionManager _sessionManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DeviceAccessHost"/> class. + /// </summary> + /// <param name="userManager">The <see cref="IUserManager"/>.</param> + /// <param name="deviceManager">The <see cref="IDeviceManager"/>.</param> + /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param> + public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager) + { + _userManager = userManager; + _deviceManager = deviceManager; + _sessionManager = sessionManager; + } + + /// <inheritdoc /> + public Task StartAsync(CancellationToken cancellationToken) + { + _userManager.OnUserUpdated += OnUserUpdated; + + return Task.CompletedTask; + } + + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) + { + _userManager.OnUserUpdated -= OnUserUpdated; + + return Task.CompletedTask; + } + + private async void OnUserUpdated(object? sender, GenericEventArgs<User> e) + { + var user = e.Argument; + if (!user.HasPermission(PermissionKind.EnableAllDevices)) + { + await UpdateDeviceAccess(user).ConfigureAwait(false); + } + } + + private async Task UpdateDeviceAccess(User user) + { + var existing = (await _deviceManager.GetDevices(new DeviceQuery + { + UserId = user.Id + }).ConfigureAwait(false)).Items; + + foreach (var device in existing) + { + if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId)) + { + await _sessionManager.Logout(device).ConfigureAwait(false); + } + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 7d5f22545d..558ad5b7bd 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -4,7 +4,10 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; using System.Text; +using Emby.Server.Implementations.EntryPoints; using Jellyfin.Api.Middleware; +using Jellyfin.LiveTv; +using Jellyfin.LiveTv.EmbyTV; using Jellyfin.LiveTv.Extensions; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking; @@ -17,6 +20,7 @@ using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; +using MediaBrowser.XbmcMetadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; @@ -124,7 +128,13 @@ namespace Jellyfin.Server services.AddHlsPlaylistGenerator(); services.AddLiveTvServices(); + services.AddHostedService<LiveTvHost>(); services.AddHostedService<AutoDiscoveryHost>(); + services.AddHostedService<PortForwardingHost>(); + services.AddHostedService<NfoUserDataSaver>(); + services.AddHostedService<LibraryChangedNotifier>(); + services.AddHostedService<UserDataChangeNotifier>(); + services.AddHostedService<RecordingNotifier>(); } /// <summary> diff --git a/MediaBrowser.Common/Progress/ActionableProgress.cs b/MediaBrowser.Common/Progress/ActionableProgress.cs deleted file mode 100644 index 0ba46ea3ba..0000000000 --- a/MediaBrowser.Common/Progress/ActionableProgress.cs +++ /dev/null @@ -1,37 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable CA1003 - -using System; - -namespace MediaBrowser.Common.Progress -{ - /// <summary> - /// Class ActionableProgress. - /// </summary> - /// <typeparam name="T">The type for the action parameter.</typeparam> - public class ActionableProgress<T> : IProgress<T> - { - /// <summary> - /// The _actions. - /// </summary> - private Action<T>? _action; - - public event EventHandler<T>? ProgressChanged; - - /// <summary> - /// Registers the action. - /// </summary> - /// <param name="action">The action.</param> - public void RegisterAction(Action<T> action) - { - _action = action; - } - - public void Report(T value) - { - ProgressChanged?.Invoke(this, value); - - _action?.Invoke(value); - } - } -} diff --git a/MediaBrowser.Common/Progress/SimpleProgress.cs b/MediaBrowser.Common/Progress/SimpleProgress.cs deleted file mode 100644 index 7071f2bc3d..0000000000 --- a/MediaBrowser.Common/Progress/SimpleProgress.cs +++ /dev/null @@ -1,17 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable CA1003 - -using System; - -namespace MediaBrowser.Common.Progress -{ - public class SimpleProgress<T> : IProgress<T> - { - public event EventHandler<T>? ProgressChanged; - - public void Report(T value) - { - ProgressChanged?.Invoke(this, value); - } - } -} diff --git a/MediaBrowser.Controller/Channels/Channel.cs b/MediaBrowser.Controller/Channels/Channel.cs index 94418683b2..f186523b9a 100644 --- a/MediaBrowser.Controller/Channels/Channel.cs +++ b/MediaBrowser.Controller/Channels/Channel.cs @@ -9,7 +9,6 @@ using System.Text.Json.Serialization; using System.Threading; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Querying; @@ -53,7 +52,7 @@ namespace MediaBrowser.Controller.Channels query.ChannelIds = new Guid[] { Id }; // Don't blow up here because it could cause parent screens with other content to fail - return ChannelManager.GetChannelItemsInternal(query, new SimpleProgress<double>(), CancellationToken.None).GetAwaiter().GetResult(); + return ChannelManager.GetChannelItemsInternal(query, new Progress<double>(), CancellationToken.None).GetAwaiter().GetResult(); } catch { diff --git a/MediaBrowser.Controller/Drawing/ImageStream.cs b/MediaBrowser.Controller/Drawing/ImageStream.cs deleted file mode 100644 index f4c3057993..0000000000 --- a/MediaBrowser.Controller/Drawing/ImageStream.cs +++ /dev/null @@ -1,42 +0,0 @@ -#pragma warning disable CA1711, CS1591 - -using System; -using System.IO; -using MediaBrowser.Model.Drawing; - -namespace MediaBrowser.Controller.Drawing -{ - public class ImageStream : IDisposable - { - public ImageStream(Stream stream) - { - Stream = stream; - } - - /// <summary> - /// Gets the stream. - /// </summary> - /// <value>The stream.</value> - public Stream Stream { get; } - - /// <summary> - /// Gets or sets the format. - /// </summary> - /// <value>The format.</value> - public ImageFormat Format { get; set; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - Stream?.Dispose(); - } - } - } -} diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 74eb089de3..1f13c833b6 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -13,7 +13,6 @@ using System.Threading.Tasks.Dataflow; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; @@ -429,16 +428,22 @@ namespace MediaBrowser.Controller.Entities if (recursive) { - var innerProgress = new ActionableProgress<double>(); - var folder = this; - innerProgress.RegisterAction(innerPercent => + var innerProgress = new Progress<double>(innerPercent => { var percent = ProgressHelpers.GetProgress(ProgressHelpers.UpdatedChildItems, ProgressHelpers.ScannedSubfolders, innerPercent); progress.Report(percent); - ProviderManager.OnRefreshProgress(folder, percent); + // TODO: this is sometimes being called after the refresh has completed. + try + { + ProviderManager.OnRefreshProgress(folder, percent); + } + catch (InvalidOperationException e) + { + Logger.LogError(e, "Error refreshing folder"); + } }); if (validChildrenNeedGeneration) @@ -461,10 +466,8 @@ namespace MediaBrowser.Controller.Entities var container = this as IMetadataContainer; - var innerProgress = new ActionableProgress<double>(); - var folder = this; - innerProgress.RegisterAction(innerPercent => + var innerProgress = new Progress<double>(innerPercent => { var percent = ProgressHelpers.GetProgress(ProgressHelpers.ScannedSubfolders, ProgressHelpers.RefreshedMetadata, innerPercent); @@ -472,7 +475,15 @@ namespace MediaBrowser.Controller.Entities if (recursive) { - ProviderManager.OnRefreshProgress(folder, percent); + // TODO: this is sometimes being called after the refresh has completed. + try + { + ProviderManager.OnRefreshProgress(folder, percent); + } + catch (InvalidOperationException e) + { + Logger.LogError(e, "Error refreshing folder"); + } } }); @@ -572,9 +583,7 @@ namespace MediaBrowser.Controller.Entities var actionBlock = new ActionBlock<int>( async i => { - var innerProgress = new ActionableProgress<double>(); - - innerProgress.RegisterAction(innerPercent => + var innerProgress = new Progress<double>(innerPercent => { // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls var innerPercentRounded = Math.Round(innerPercent); @@ -922,7 +931,7 @@ namespace MediaBrowser.Controller.Entities query.ChannelIds = new[] { ChannelId }; // Don't blow up here because it could cause parent screens with other content to fail - return ChannelManager.GetChannelItemsInternal(query, new SimpleProgress<double>(), CancellationToken.None).GetAwaiter().GetResult(); + return ChannelManager.GetChannelItemsInternal(query, new Progress<double>(), CancellationToken.None).GetAwaiter().GetResult(); } catch { diff --git a/MediaBrowser.Controller/Library/ILibraryMonitor.cs b/MediaBrowser.Controller/Library/ILibraryMonitor.cs index de74aa5a11..6d2f5b8738 100644 --- a/MediaBrowser.Controller/Library/ILibraryMonitor.cs +++ b/MediaBrowser.Controller/Library/ILibraryMonitor.cs @@ -1,10 +1,9 @@ -#pragma warning disable CS1591 - -using System; - namespace MediaBrowser.Controller.Library { - public interface ILibraryMonitor : IDisposable + /// <summary> + /// Service responsible for monitoring library filesystems for changes. + /// </summary> + public interface ILibraryMonitor { /// <summary> /// Starts this instance. diff --git a/MediaBrowser.Controller/LiveTv/IGuideManager.cs b/MediaBrowser.Controller/LiveTv/IGuideManager.cs new file mode 100644 index 0000000000..9883b9283c --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IGuideManager.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.LiveTv; + +namespace MediaBrowser.Controller.LiveTv; + +/// <summary> +/// Service responsible for managing the Live TV guide. +/// </summary> +public interface IGuideManager +{ + /// <summary> + /// Gets the guide information. + /// </summary> + /// <returns>The <see cref="GuideInfo"/>.</returns> + GuideInfo GetGuideInfo(); + + /// <summary> + /// Refresh the guide. + /// </summary> + /// <param name="progress">The <see cref="IProgress{T}"/> to use.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/> to use.</param> + /// <returns>Task representing the refresh operation.</returns> + Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 26f9fe42d3..7da455b8d4 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -68,13 +68,6 @@ namespace MediaBrowser.Controller.LiveTv Task CancelSeriesTimer(string id); /// <summary> - /// Adds the parts. - /// </summary> - /// <param name="services">The services.</param> - /// <param name="listingProviders">The listing providers.</param> - void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<IListingsProvider> listingProviders); - - /// <summary> /// Gets the timer. /// </summary> /// <param name="id">The identifier.</param> @@ -175,12 +168,6 @@ namespace MediaBrowser.Controller.LiveTv Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken); /// <summary> - /// Gets the guide information. - /// </summary> - /// <returns>GuideInfo.</returns> - GuideInfo GetGuideInfo(); - - /// <summary> /// Gets the recommended programs. /// </summary> /// <param name="query">The query.</param> diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index fb4e7bd1f5..29dd190ab7 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -88,6 +88,12 @@ namespace MediaBrowser.Controller.MediaEncoding public string Level { get; set; } /// <summary> + /// Gets or sets the codec tag. + /// </summary> + /// <value>The codec tag.</value> + public string CodecTag { get; set; } + + /// <summary> /// Gets or sets the framerate. /// </summary> /// <value>The framerate.</value> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 17813559a8..f2a0b906dc 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -619,6 +619,26 @@ namespace MediaBrowser.Controller.MediaEncoding return Array.Empty<string>(); } + public string[] GetRequestedCodecTags(string codec) + { + if (!string.IsNullOrEmpty(BaseRequest.CodecTag)) + { + return BaseRequest.CodecTag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } + + if (!string.IsNullOrEmpty(codec)) + { + var codectag = BaseRequest.GetOption(codec, "codectag"); + + if (!string.IsNullOrEmpty(codectag)) + { + return codectag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries); + } + } + + return Array.Empty<string>(); + } + public string GetRequestedLevel(string codec) { if (!string.IsNullOrEmpty(BaseRequest.Level)) diff --git a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs index c19a12ae7a..09bc01f748 100644 --- a/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs +++ b/MediaBrowser.Controller/MediaEncoding/ITranscodeManager.cs @@ -96,9 +96,10 @@ public interface ITranscodeManager public void OnTranscodeEndRequest(TranscodingJob job); /// <summary> - /// Gets the transcoding lock. + /// Transcoding lock. /// </summary> /// <param name="outputPath">The output path of the transcoded file.</param> - /// <returns>A <see cref="SemaphoreSlim"/>.</returns> - public SemaphoreSlim GetTranscodingLock(string outputPath); + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>An <see cref="IDisposable"/>.</returns> + ValueTask<IDisposable> LockAsync(string outputPath, CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs b/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs deleted file mode 100644 index 044ba6d331..0000000000 --- a/MediaBrowser.Controller/MediaEncoding/ImageEncodingOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller.MediaEncoding -{ - public class ImageEncodingOptions - { - public string InputPath { get; set; } - - public int? Width { get; set; } - - public int? Height { get; set; } - - public int? MaxWidth { get; set; } - - public int? MaxHeight { get; set; } - - public int? Quality { get; set; } - - public string Format { get; set; } - } -} diff --git a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs b/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs deleted file mode 100644 index 841e7b2872..0000000000 --- a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs +++ /dev/null @@ -1,11 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Controller.MediaEncoding -{ - /// <summary> - /// Class MediaEncoderHelpers. - /// </summary> - public static class MediaEncoderHelpers - { - } -} diff --git a/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs b/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs deleted file mode 100644 index 2b831103a5..0000000000 --- a/MediaBrowser.Controller/Plugins/IRunBeforeStartup.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace MediaBrowser.Controller.Plugins -{ - /// <summary> - /// Indicates that a <see cref="IServerEntryPoint"/> should be invoked as a pre-startup task. - /// </summary> - public interface IRunBeforeStartup - { - } -} diff --git a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs b/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs deleted file mode 100644 index 6024661e15..0000000000 --- a/MediaBrowser.Controller/Plugins/IServerEntryPoint.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace MediaBrowser.Controller.Plugins -{ - /// <summary> - /// Represents an entry point for a module in the application. This interface is scanned for automatically and - /// provides a hook to initialize the module at application start. - /// The entry point can additionally be flagged as a pre-startup task by implementing the - /// <see cref="IRunBeforeStartup"/> interface. - /// </summary> - public interface IServerEntryPoint : IDisposable - { - /// <summary> - /// Run the initialization for this module. This method is invoked at application start. - /// </summary> - /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> - Task RunAsync(); - } -} diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 299f294b29..ff91a60a79 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; @@ -22,7 +23,7 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Attachments { - public sealed class AttachmentExtractor : IAttachmentExtractor + public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable { private readonly ILogger<AttachmentExtractor> _logger; private readonly IApplicationPaths _appPaths; @@ -30,8 +31,11 @@ namespace MediaBrowser.MediaEncoding.Attachments private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; - private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks = - new ConcurrentDictionary<string, SemaphoreSlim>(); + private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o => + { + o.PoolSize = 20; + o.PoolInitialFill = 1; + }); public AttachmentExtractor( ILogger<AttachmentExtractor> logger, @@ -84,11 +88,7 @@ namespace MediaBrowser.MediaEncoding.Attachments string outputPath, CancellationToken cancellationToken) { - var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1)); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!Directory.Exists(outputPath)) { @@ -99,10 +99,6 @@ namespace MediaBrowser.MediaEncoding.Attachments cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } public async Task ExtractAllAttachmentsExternal( @@ -111,11 +107,7 @@ namespace MediaBrowser.MediaEncoding.Attachments string outputPath, CancellationToken cancellationToken) { - var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1)); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(Path.Join(outputPath, id))) { @@ -131,10 +123,6 @@ namespace MediaBrowser.MediaEncoding.Attachments } } } - finally - { - semaphore.Release(); - } } private async Task ExtractAllAttachmentsInternal( @@ -256,11 +244,7 @@ namespace MediaBrowser.MediaEncoding.Attachments string outputPath, CancellationToken cancellationToken) { - var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1)); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { @@ -271,10 +255,6 @@ namespace MediaBrowser.MediaEncoding.Attachments cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } private async Task ExtractAttachmentInternal( @@ -379,5 +359,11 @@ namespace MediaBrowser.MediaEncoding.Attachments var prefix = filename.AsSpan(0, 1); return Path.Join(_appPaths.DataPath, "attachments", prefix, filename); } + + /// <inheritdoc /> + public void Dispose() + { + _semaphoreLocks.Dispose(); + } } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 4dbefca4bb..f86d14fc86 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; @@ -60,7 +61,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly IServerConfigurationManager _serverConfig; private readonly string _startupOptionFFmpegPath; - private readonly SemaphoreSlim _thumbnailResourcePool; + private readonly AsyncNonKeyedLocker _thumbnailResourcePool; private readonly object _runningProcessesLock = new object(); private readonly List<ProcessWrapper> _runningProcesses = new List<ProcessWrapper>(); @@ -116,7 +117,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _jsonSerializerOptions.Converters.Add(new JsonBoolStringConverter()); var semaphoreCount = 2 * Environment.ProcessorCount; - _thumbnailResourcePool = new SemaphoreSlim(semaphoreCount, semaphoreCount); + _thumbnailResourcePool = new(semaphoreCount); } /// <inheritdoc /> @@ -754,8 +755,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { bool ranToCompletion; - await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { StartProcess(processWrapper); @@ -776,10 +776,6 @@ namespace MediaBrowser.MediaEncoding.Encoder ranToCompletion = false; } } - finally - { - _thumbnailResourcePool.Release(); - } var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1; var file = _fileSystem.GetFileInfo(tempExtractPath); @@ -908,8 +904,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { bool ranToCompletion = false; - await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { StartProcess(processWrapper); @@ -963,10 +958,6 @@ namespace MediaBrowser.MediaEncoding.Encoder StopProcess(processWrapper, 1000); } } - finally - { - _thumbnailResourcePool.Release(); - } var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1; diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index a4e8194c15..be63513a72 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> <PropertyGroup> @@ -22,6 +22,7 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="BDInfo" /> <PackageReference Include="libse" /> <PackageReference Include="Microsoft.Extensions.Http" /> diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 459d854bf1..8f1cc3f645 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -11,6 +11,7 @@ using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -27,7 +28,7 @@ using UtfUnknown; namespace MediaBrowser.MediaEncoding.Subtitles { - public sealed class SubtitleEncoder : ISubtitleEncoder + public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable { private readonly ILogger<SubtitleEncoder> _logger; private readonly IApplicationPaths _appPaths; @@ -40,8 +41,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <summary> /// The _semaphoreLocks. /// </summary> - private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks = - new ConcurrentDictionary<string, SemaphoreSlim>(); + private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o => + { + o.PoolSize = 20; + o.PoolInitialFill = 1; + }); public SubtitleEncoder( ILogger<SubtitleEncoder> logger, @@ -194,36 +198,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles { if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - string outputFormat; - string outputCodec; + await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false); - if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) - || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase) - || string.Equals(subtitleStream.Codec, "srt", StringComparison.OrdinalIgnoreCase)) - { - // Extract - outputCodec = "copy"; - outputFormat = subtitleStream.Codec; - } - else if (string.Equals(subtitleStream.Codec, "subrip", StringComparison.OrdinalIgnoreCase)) - { - // Extract - outputCodec = "copy"; - outputFormat = "srt"; - } - else - { - // Extract - outputCodec = "srt"; - outputFormat = "srt"; - } - - // Extract + var outputFormat = GetTextSubtitleFormat(subtitleStream); var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat); - await ExtractTextSubtitle(mediaSource, subtitleStream, outputCodec, outputPath, cancellationToken) - .ConfigureAwait(false); - return new SubtitleInfo() { Path = outputPath, @@ -318,16 +297,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles } /// <summary> - /// Gets the lock. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.Object.</returns> - private SemaphoreSlim GetLock(string filename) - { - return _semaphoreLocks.GetOrAdd(filename, _ => new SemaphoreSlim(1, 1)); - } - - /// <summary> /// Converts the text subtitle to SRT. /// </summary> /// <param name="subtitleStream">The subtitle stream.</param> @@ -337,21 +306,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <returns>Task.</returns> private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken) { - var semaphore = GetLock(outputPath); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } /// <summary> @@ -467,6 +428,203 @@ namespace MediaBrowser.MediaEncoding.Subtitles _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } + private string GetTextSubtitleFormat(MediaStream subtitleStream) + { + if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)) + { + return subtitleStream.Codec; + } + else + { + return "srt"; + } + } + + private bool IsCodecCopyable(string codec) + { + return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase); + } + + /// <summary> + /// Extracts all text subtitles. + /// </summary> + /// <param name="mediaSource">The mediaSource.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) + { + var locks = new List<AsyncKeyedLockReleaser<string>>(); + var extractableStreams = new List<MediaStream>(); + + try + { + var subtitleStreams = mediaSource.MediaStreams + .Where(stream => stream.IsTextSubtitleStream && stream.SupportsExternalStream); + + foreach (var subtitleStream in subtitleStreams) + { + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + + var @lock = _semaphoreLocks.GetOrAdd(outputPath); + await @lock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (File.Exists(outputPath)) + { + @lock.Dispose(); + continue; + } + + locks.Add(@lock); + extractableStreams.Add(subtitleStream); + } + + if (extractableStreams.Count > 0) + { + await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path); + } + finally + { + foreach (var @lock in locks) + { + @lock.Dispose(); + } + } + } + + private async Task ExtractAllTextSubtitlesInternal( + MediaSourceInfo mediaSource, + List<MediaStream> subtitleStreams, + CancellationToken cancellationToken) + { + var inputPath = mediaSource.Path; + var outputPaths = new List<string>(); + var args = string.Format( + CultureInfo.InvariantCulture, + "-i {0} -copyts", + inputPath); + + foreach (var subtitleStream in subtitleStreams) + { + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; + var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); + + if (streamIndex == -1) + { + _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid.")); + + outputPaths.Add(outputPath); + args += string.Format( + CultureInfo.InvariantCulture, + " -map 0:{0} -an -vn -c:s {1} \"{2}\"", + streamIndex, + outputCodec, + outputPath); + } + + int exitCode; + + using (var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + FileName = _mediaEncoder.EncoderPath, + Arguments = args, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + EnableRaisingEvents = true + }) + { + _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting ffmpeg"); + + throw; + } + + try + { + await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; + } + } + + var failed = false; + + if (exitCode == -1) + { + failed = true; + + foreach (var outputPath in outputPaths) + { + try + { + _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath); + _fileSystem.DeleteFile(outputPath); + } + catch (FileNotFoundException) + { + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); + } + } + } + else + { + foreach (var outputPath in outputPaths) + { + if (!File.Exists(outputPath)) + { + _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); + failed = true; + continue; + } + + if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase)) + { + await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); + } + } + + if (failed) + { + throw new FfmpegException( + string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath)); + } + } + /// <summary> /// Extracts the text subtitle. /// </summary> @@ -484,16 +642,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles string outputPath, CancellationToken cancellationToken) { - var semaphore = GetLock(outputPath); - - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - - var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); - - try + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { if (!File.Exists(outputPath)) { + var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); + var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource); if (subtitleStream.IsExternal) @@ -509,10 +663,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles cancellationToken).ConfigureAwait(false); } } - finally - { - semaphore.Release(); - } } private async Task ExtractTextSubtitleInternal( @@ -728,6 +878,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } + /// <inheritdoc /> + public void Dispose() + { + _semaphoreLocks.Dispose(); + } + #pragma warning disable CA1034 // Nested types should not be visible // Only public for the unit tests public readonly record struct SubtitleInfo diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index d79e4441aa..ab3eb3298b 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -4,10 +4,12 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common; @@ -43,7 +45,11 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable private readonly IAttachmentExtractor _attachmentExtractor; private readonly List<TranscodingJob> _activeTranscodingJobs = new(); - private readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new(); + private readonly AsyncKeyedLocker<string> _transcodingLocks = new(o => + { + o.PoolSize = 20; + o.PoolInitialFill = 1; + }); /// <summary> /// Initializes a new instance of the <see cref="TranscodeManager"/> class. @@ -224,11 +230,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - lock (_transcodingLocks) - { - _transcodingLocks.Remove(job.Path!); - } - job.Stop(); if (delete(job.Path!)) @@ -625,11 +626,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - lock (_transcodingLocks) - { - _transcodingLocks.Remove(path); - } - if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) { _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); @@ -705,21 +701,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } - /// <inheritdoc /> - public SemaphoreSlim GetTranscodingLock(string outputPath) - { - lock (_transcodingLocks) - { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) - { - result = new SemaphoreSlim(1, 1); - _transcodingLocks[outputPath] = result; - } - - return result; - } - } - private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) { if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) @@ -742,10 +723,23 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable } } + /// <summary> + /// Transcoding lock. + /// </summary> + /// <param name="outputPath">The output path of the transcoded file.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>An <see cref="IDisposable"/>.</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ValueTask<IDisposable> LockAsync(string outputPath, CancellationToken cancellationToken) + { + return _transcodingLocks.LockAsync(outputPath, cancellationToken); + } + /// <inheritdoc /> public void Dispose() { _sessionManager.PlaybackProgress -= OnPlaybackProgress; _sessionManager.PlaybackStart -= OnPlaybackProgress; + _transcodingLocks.Dispose(); } } diff --git a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs b/MediaBrowser.Model/ClientLog/ClientLogEvent.cs deleted file mode 100644 index 21087b5647..0000000000 --- a/MediaBrowser.Model/ClientLog/ClientLogEvent.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Model.ClientLog -{ - /// <summary> - /// The client log event. - /// </summary> - public class ClientLogEvent - { - /// <summary> - /// Initializes a new instance of the <see cref="ClientLogEvent"/> class. - /// </summary> - /// <param name="timestamp">The log timestamp.</param> - /// <param name="level">The log level.</param> - /// <param name="userId">The user id.</param> - /// <param name="clientName">The client name.</param> - /// <param name="clientVersion">The client version.</param> - /// <param name="deviceId">The device id.</param> - /// <param name="message">The message.</param> - public ClientLogEvent( - DateTime timestamp, - LogLevel level, - Guid? userId, - string clientName, - string clientVersion, - string deviceId, - string message) - { - Timestamp = timestamp; - UserId = userId; - ClientName = clientName; - ClientVersion = clientVersion; - DeviceId = deviceId; - Message = message; - Level = level; - } - - /// <summary> - /// Gets the event timestamp. - /// </summary> - public DateTime Timestamp { get; } - - /// <summary> - /// Gets the log level. - /// </summary> - public LogLevel Level { get; } - - /// <summary> - /// Gets the user id. - /// </summary> - public Guid? UserId { get; } - - /// <summary> - /// Gets the client name. - /// </summary> - public string ClientName { get; } - - /// <summary> - /// Gets the client version. - /// </summary> - public string ClientVersion { get; } - - /// - /// <summary> - /// Gets the device id. - /// </summary> - public string DeviceId { get; } - - /// <summary> - /// Gets the log message. - /// </summary> - public string Message { get; } - } -} diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index da683a17e6..e6b7f4d9b3 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1944,6 +1944,38 @@ namespace MediaBrowser.Model.Dlna break; } + case ProfileConditionValue.VideoCodecTag: + { + if (string.IsNullOrEmpty(qualifier)) + { + continue; + } + + // change from split by | to comma + // strip spaces to avoid having to encode + var values = value + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (condition.Condition == ProfileConditionType.Equals) + { + item.SetOption(qualifier, "codectag", string.Join(',', values)); + } + else if (condition.Condition == ProfileConditionType.EqualsAny) + { + var currentValue = item.GetOption(qualifier, "codectag"); + if (!string.IsNullOrEmpty(currentValue) && values.Any(v => string.Equals(v, currentValue, StringComparison.OrdinalIgnoreCase))) + { + item.SetOption(qualifier, "codectag", currentValue); + } + else + { + item.SetOption(qualifier, "codectag", string.Join(',', values)); + } + } + + break; + } + case ProfileConditionValue.Height: { if (!enableNonQualifiedConditions) diff --git a/MediaBrowser.Model/Dto/ImageByNameInfo.cs b/MediaBrowser.Model/Dto/ImageByNameInfo.cs deleted file mode 100644 index 06cc3e73cf..0000000000 --- a/MediaBrowser.Model/Dto/ImageByNameInfo.cs +++ /dev/null @@ -1,38 +0,0 @@ -#nullable disable -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Dto -{ - public class ImageByNameInfo - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - /// <summary> - /// Gets or sets the theme. - /// </summary> - /// <value>The theme.</value> - public string Theme { get; set; } - - /// <summary> - /// Gets or sets the context. - /// </summary> - /// <value>The context.</value> - public string Context { get; set; } - - /// <summary> - /// Gets or sets the length of the file. - /// </summary> - /// <value>The length of the file.</value> - public long FileLength { get; set; } - - /// <summary> - /// Gets or sets the format. - /// </summary> - /// <value>The format.</value> - public string Format { get; set; } - } -} diff --git a/MediaBrowser.Model/Entities/SpecialFolder.cs b/MediaBrowser.Model/Entities/SpecialFolder.cs deleted file mode 100644 index 2250c5dffb..0000000000 --- a/MediaBrowser.Model/Entities/SpecialFolder.cs +++ /dev/null @@ -1,36 +0,0 @@ -#pragma warning disable CS1591 - -namespace MediaBrowser.Model.Entities -{ - public static class SpecialFolder - { - public const string TvShowSeries = "TvShowSeries"; - public const string TvGenres = "TvGenres"; - public const string TvGenre = "TvGenre"; - public const string TvLatest = "TvLatest"; - public const string TvNextUp = "TvNextUp"; - public const string TvResume = "TvResume"; - public const string TvFavoriteSeries = "TvFavoriteSeries"; - public const string TvFavoriteEpisodes = "TvFavoriteEpisodes"; - - public const string MovieLatest = "MovieLatest"; - public const string MovieResume = "MovieResume"; - public const string MovieMovies = "MovieMovies"; - public const string MovieCollections = "MovieCollections"; - public const string MovieFavorites = "MovieFavorites"; - public const string MovieGenres = "MovieGenres"; - public const string MovieGenre = "MovieGenre"; - - public const string MusicArtists = "MusicArtists"; - public const string MusicAlbumArtists = "MusicAlbumArtists"; - public const string MusicAlbums = "MusicAlbums"; - public const string MusicGenres = "MusicGenres"; - public const string MusicLatest = "MusicLatest"; - public const string MusicPlaylists = "MusicPlaylists"; - public const string MusicSongs = "MusicSongs"; - public const string MusicFavorites = "MusicFavorites"; - public const string MusicFavoriteArtists = "MusicFavoriteArtists"; - public const string MusicFavoriteAlbums = "MusicFavoriteAlbums"; - public const string MusicFavoriteSongs = "MusicFavoriteSongs"; - } -} diff --git a/MediaBrowser.Model/Net/SocketReceiveResult.cs b/MediaBrowser.Model/Net/SocketReceiveResult.cs deleted file mode 100644 index 1524786ea7..0000000000 --- a/MediaBrowser.Model/Net/SocketReceiveResult.cs +++ /dev/null @@ -1,32 +0,0 @@ -#nullable disable - -using System.Net; - -namespace MediaBrowser.Model.Net -{ - /// <summary> - /// Used by the sockets wrapper to hold raw data received from a UDP socket. - /// </summary> - public sealed class SocketReceiveResult - { - /// <summary> - /// Gets or sets the buffer to place received data into. - /// </summary> - public byte[] Buffer { get; set; } - - /// <summary> - /// Gets or sets the number of bytes received. - /// </summary> - public int ReceivedBytes { get; set; } - - /// <summary> - /// Gets or sets the <see cref="IPEndPoint"/> the data was received from. - /// </summary> - public IPEndPoint RemoteEndPoint { get; set; } - - /// <summary> - /// Gets or sets the local <see cref="IPAddress"/>. - /// </summary> - public IPAddress LocalIPAddress { get; set; } - } -} diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs index 597845fc17..5f51fb21c3 100644 --- a/MediaBrowser.Model/Session/ClientCapabilities.cs +++ b/MediaBrowser.Model/Session/ClientCapabilities.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using Jellyfin.Data.Enums; using MediaBrowser.Model.Dlna; @@ -30,5 +31,15 @@ namespace MediaBrowser.Model.Session public string AppStoreUrl { get; set; } public string IconUrl { get; set; } + + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsContentUploading { get; set; } + + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsSync { get; set; } } } diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index aa7c03ebd5..f37ac6a147 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -3,30 +3,12 @@ using System; using System.Collections.Generic; -using System.Runtime.InteropServices; +using System.ComponentModel; using MediaBrowser.Model.Updates; namespace MediaBrowser.Model.System { /// <summary> - /// Enum describing the location of the FFmpeg tool. - /// </summary> - public enum FFmpegLocation - { - /// <summary>No path to FFmpeg found.</summary> - NotFound, - - /// <summary>Path supplied via command line using switch --ffmpeg.</summary> - SetByArgument, - - /// <summary>User has supplied path via Transcoding UI page.</summary> - Custom, - - /// <summary>FFmpeg tool found on system $PATH.</summary> - System - } - - /// <summary> /// Class SystemInfo. /// </summary> public class SystemInfo : PublicSystemInfo @@ -83,9 +65,11 @@ namespace MediaBrowser.Model.System /// </summary> /// <value><c>true</c>.</value> [Obsolete("This is always true")] + [DefaultValue(true)] public bool CanSelfRestart { get; set; } = true; [Obsolete("This is always false")] + [DefaultValue(false)] public bool CanLaunchWebBrowser { get; set; } = false; /// <summary> @@ -140,12 +124,15 @@ namespace MediaBrowser.Model.System /// </summary> /// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value> [Obsolete("This should be handled by the package manager")] + [DefaultValue(false)] public bool HasUpdateAvailable { get; set; } [Obsolete("This isn't set correctly anymore")] - public FFmpegLocation EncoderLocation { get; set; } + [DefaultValue("System")] + public string EncoderLocation { get; set; } = "System"; [Obsolete("This is no longer set")] - public Architecture SystemArchitecture { get; set; } = Architecture.X64; + [DefaultValue("X64")] + public string SystemArchitecture { get; set; } = "X64"; } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index b530b9de3f..2e9547bf31 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -13,7 +13,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.BaseItemManager; using MediaBrowser.Controller.Configuration; @@ -1025,7 +1024,7 @@ namespace MediaBrowser.Providers.Manager await RefreshCollectionFolderChildren(options, collectionFolder, cancellationToken).ConfigureAwait(false); break; case Folder folder: - await folder.ValidateChildren(new SimpleProgress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false); + await folder.ValidateChildren(new Progress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false); break; } } @@ -1036,7 +1035,7 @@ namespace MediaBrowser.Providers.Manager { await child.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); - await child.ValidateChildren(new SimpleProgress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false); + await child.ValidateChildren(new Progress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false); } } @@ -1058,7 +1057,7 @@ namespace MediaBrowser.Providers.Manager .Select(i => i.MusicArtist) .Where(i => i is not null); - var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new SimpleProgress<double>(), options, true, cancellationToken)); + var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new Progress<double>(), options, true, cancellationToken)); await Task.WhenAll(musicArtistRefreshTasks).ConfigureAwait(false); diff --git a/MediaBrowser.XbmcMetadata/EntryPoint.cs b/MediaBrowser.XbmcMetadata/EntryPoint.cs deleted file mode 100644 index a6216ef305..0000000000 --- a/MediaBrowser.XbmcMetadata/EntryPoint.cs +++ /dev/null @@ -1,78 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.XbmcMetadata.Configuration; -using MediaBrowser.XbmcMetadata.Savers; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.XbmcMetadata -{ - public sealed class EntryPoint : IServerEntryPoint - { - private readonly IUserDataManager _userDataManager; - private readonly ILogger<EntryPoint> _logger; - private readonly IProviderManager _providerManager; - private readonly IConfigurationManager _config; - - public EntryPoint( - IUserDataManager userDataManager, - ILogger<EntryPoint> logger, - IProviderManager providerManager, - IConfigurationManager config) - { - _userDataManager = userDataManager; - _logger = logger; - _providerManager = providerManager; - _config = config; - } - - /// <inheritdoc /> - public Task RunAsync() - { - _userDataManager.UserDataSaved += OnUserDataSaved; - - return Task.CompletedTask; - } - - private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) - { - if (e.SaveReason == UserDataSaveReason.PlaybackFinished || e.SaveReason == UserDataSaveReason.TogglePlayed || e.SaveReason == UserDataSaveReason.UpdateUserRating) - { - if (!string.IsNullOrWhiteSpace(_config.GetNfoConfiguration().UserId)) - { - _ = SaveMetadataForItemAsync(e.Item, ItemUpdateType.MetadataDownload); - } - } - } - - /// <inheritdoc /> - public void Dispose() - { - _userDataManager.UserDataSaved -= OnUserDataSaved; - } - - private async Task SaveMetadataForItemAsync(BaseItem item, ItemUpdateType updateReason) - { - if (!item.IsFileProtocol || !item.SupportsLocalMetadata) - { - return; - } - - try - { - await _providerManager.SaveMetadataAsync(item, updateReason, new[] { BaseNfoSaver.SaverName }).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving metadata for {Path}", item.Path ?? item.Name); - } - } - } -} diff --git a/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs b/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs new file mode 100644 index 0000000000..b2882194dd --- /dev/null +++ b/MediaBrowser.XbmcMetadata/NfoUserDataSaver.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.XbmcMetadata.Configuration; +using MediaBrowser.XbmcMetadata.Savers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.XbmcMetadata; + +/// <summary> +/// <see cref="IHostedService"/> responsible for updating NFO files' user data. +/// </summary> +public sealed class NfoUserDataSaver : IHostedService +{ + private readonly ILogger<NfoUserDataSaver> _logger; + private readonly IConfigurationManager _config; + private readonly IUserDataManager _userDataManager; + private readonly IProviderManager _providerManager; + + /// <summary> + /// Initializes a new instance of the <see cref="NfoUserDataSaver"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger"/>.</param> + /// <param name="config">The <see cref="IConfigurationManager"/>.</param> + /// <param name="userDataManager">The <see cref="IUserDataManager"/>.</param> + /// <param name="providerManager">The <see cref="IProviderManager"/>.</param> + public NfoUserDataSaver( + ILogger<NfoUserDataSaver> logger, + IConfigurationManager config, + IUserDataManager userDataManager, + IProviderManager providerManager) + { + _logger = logger; + _config = config; + _userDataManager = userDataManager; + _providerManager = providerManager; + } + + /// <inheritdoc /> + public Task StartAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved += OnUserDataSaved; + return Task.CompletedTask; + } + + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= OnUserDataSaved; + return Task.CompletedTask; + } + + private async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) + { + if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished + or UserDataSaveReason.TogglePlayed or UserDataSaveReason.UpdateUserRating)) + { + return; + } + + if (string.IsNullOrWhiteSpace(_config.GetNfoConfiguration().UserId)) + { + return; + } + + var item = e.Item; + if (!item.IsFileProtocol || !item.SupportsLocalMetadata) + { + return; + } + + try + { + await _providerManager.SaveMetadataAsync(item, ItemUpdateType.MetadataDownload, [BaseNfoSaver.SaverName]) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving metadata for {Path}", item.Path ?? item.Name); + } + } +} diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 70e5b66c1e..97cdc68545 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -460,10 +460,28 @@ namespace MediaBrowser.XbmcMetadata.Parsers var trailer = reader.ReadNormalizedString(); if (!string.IsNullOrEmpty(trailer)) { - item.AddTrailerUrl(trailer.Replace( - "plugin://plugin.video.youtube/?action=play_video&videoid=", - BaseNfoSaver.YouTubeWatchUrl, - StringComparison.OrdinalIgnoreCase)); + if (trailer.StartsWith("plugin://plugin.video.youtube/?action=play_video&videoid=", StringComparison.OrdinalIgnoreCase)) + { + // Deprecated format + item.AddTrailerUrl(trailer.Replace( + "plugin://plugin.video.youtube/?action=play_video&videoid=", + BaseNfoSaver.YouTubeWatchUrl, + StringComparison.OrdinalIgnoreCase)); + + var suggestedUrl = trailer.Replace( + "plugin://plugin.video.youtube/?action=play_video&videoid=", + "plugin://plugin.video.youtube/play/?video_id=", + StringComparison.OrdinalIgnoreCase); + Logger.LogWarning("Trailer URL uses a deprecated format : {Url}. Using {NewUrl} instead is advised.", trailer, suggestedUrl); + } + else if (trailer.StartsWith("plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase)) + { + // Proper format + item.AddTrailerUrl(trailer.Replace( + "plugin://plugin.video.youtube/play/?video_id=", + BaseNfoSaver.YouTubeWatchUrl, + StringComparison.OrdinalIgnoreCase)); + } } break; @@ -142,6 +142,13 @@ cd Jellyfin.Server/bin/Debug/net8.0 # Change into the build output directory 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. +#### Accessing the Hosted Web Client + +If the Server is configured to host the Web Client, and the Server is running, the Web Client can be accessed at `http://localhost:8096` by default. + +API documentation can be viewed at `http://localhost:8096/api-docs/swagger/index.html` + + ### Running from GH-Codespaces As Jellyfin will run on a container on a github hosted server, JF needs to handle some things differently. diff --git a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs b/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs deleted file mode 100644 index 581fa000dc..0000000000 --- a/src/Jellyfin.Drawing.Skia/SkiaCodecException.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Globalization; -using SkiaSharp; - -namespace Jellyfin.Drawing.Skia; - -/// <summary> -/// Represents errors that occur during interaction with Skia codecs. -/// </summary> -public class SkiaCodecException : SkiaException -{ - /// <summary> - /// Initializes a new instance of the <see cref="SkiaCodecException" /> class. - /// </summary> - /// <param name="result">The non-successful codec result returned by Skia.</param> - public SkiaCodecException(SKCodecResult result) - { - CodecResult = result; - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaCodecException" /> class - /// with a specified error message. - /// </summary> - /// <param name="result">The non-successful codec result returned by Skia.</param> - /// <param name="message">The message that describes the error.</param> - public SkiaCodecException(SKCodecResult result, string message) - : base(message) - { - CodecResult = result; - } - - /// <summary> - /// Gets the non-successful codec result returned by Skia. - /// </summary> - public SKCodecResult CodecResult { get; } - - /// <inheritdoc /> - public override string ToString() - => string.Format( - CultureInfo.InvariantCulture, - "Non-success codec result: {0}\n{1}", - CodecResult, - base.ToString()); -} diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 5721e28820..4ae5a9a483 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -182,7 +182,6 @@ public class SkiaEncoder : IImageEncoder /// <inheritdoc /> /// <exception cref="ArgumentNullException">The path is null.</exception> /// <exception cref="FileNotFoundException">The path is not valid.</exception> - /// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception> public string GetImageBlurHash(int xComp, int yComp, string path) { ArgumentException.ThrowIfNullOrEmpty(path); diff --git a/src/Jellyfin.Drawing.Skia/SkiaException.cs b/src/Jellyfin.Drawing.Skia/SkiaException.cs deleted file mode 100644 index d0e69d42c8..0000000000 --- a/src/Jellyfin.Drawing.Skia/SkiaException.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; - -namespace Jellyfin.Drawing.Skia; - -/// <summary> -/// Represents errors that occur during interaction with Skia. -/// </summary> -public class SkiaException : Exception -{ - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class. - /// </summary> - public SkiaException() - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message. - /// </summary> - /// <param name="message">The message that describes the error.</param> - public SkiaException(string message) : base(message) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a - /// reference to the inner exception that is the cause of this exception. - /// </summary> - /// <param name="message">The error message that explains the reason for the exception.</param> - /// <param name="innerException"> - /// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if - /// no inner exception is specified. - /// </param> - public SkiaException(string message, Exception innerException) - : base(message, innerException) - { - } -} diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 65a8f4e832..213328a39f 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -7,6 +7,7 @@ using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -38,7 +39,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable private readonly IServerApplicationPaths _appPaths; private readonly IImageEncoder _imageEncoder; - private readonly SemaphoreSlim _parallelEncodingLimit; + private readonly AsyncNonKeyedLocker _parallelEncodingLimit; private bool _disposed; @@ -68,7 +69,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable semaphoreCount = 2 * Environment.ProcessorCount; } - _parallelEncodingLimit = new(semaphoreCount, semaphoreCount); + _parallelEncodingLimit = new(semaphoreCount); } private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images"); @@ -193,18 +194,13 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable { if (!File.Exists(cacheFilePath)) { - // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage - await _parallelEncodingLimit.WaitAsync().ConfigureAwait(false); - string resultPath; - try + + // Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage + using (await _parallelEncodingLimit.LockAsync().ConfigureAwait(false)) { resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat); } - finally - { - _parallelEncodingLimit.Release(); - } if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 23c4c0a9a4..4a02f90f95 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -21,4 +21,8 @@ <Compile Include="..\..\SharedVersion.cs" /> </ItemGroup> + <ItemGroup> + <PackageReference Include="AsyncKeyedLock" /> + </ItemGroup> + </Project> diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index bc968f8ee1..1948a9ab9b 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -8,12 +8,12 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -50,7 +50,7 @@ namespace Jellyfin.LiveTv.Channels private readonly IFileSystem _fileSystem; private readonly IProviderManager _providerManager; private readonly IMemoryCache _memoryCache; - private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _resourcePool = new(1); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private bool _disposed = false; @@ -667,7 +667,7 @@ namespace Jellyfin.LiveTv.Channels ChannelIds = new Guid[] { internalChannel.Id } }; - var result = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + var result = await GetChannelItemsInternal(query, new Progress<double>(), cancellationToken).ConfigureAwait(false); foreach (var item in result.Items) { @@ -680,7 +680,7 @@ namespace Jellyfin.LiveTv.Channels EnableTotalRecordCount = false, ChannelIds = new Guid[] { internalChannel.Id } }, - new SimpleProgress<double>(), + new Progress<double>(), cancellationToken).ConfigureAwait(false); } } @@ -762,7 +762,7 @@ namespace Jellyfin.LiveTv.Channels /// <inheritdoc /> public async Task<QueryResult<BaseItemDto>> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken) { - var internalResult = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + var internalResult = await GetChannelItemsInternal(query, new Progress<double>(), cancellationToken).ConfigureAwait(false); var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, query.DtoOptions, query.User); @@ -811,9 +811,7 @@ namespace Jellyfin.LiveTv.Channels { } - await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) { try { @@ -860,10 +858,6 @@ namespace Jellyfin.LiveTv.Channels return result; } - finally - { - _resourcePool.Release(); - } } private async Task CacheResponse(ChannelItemResult result, string path) diff --git a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs index 556e052d4e..79c5873d51 100644 --- a/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs +++ b/src/Jellyfin.LiveTv/Channels/RefreshChannelsScheduledTask.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Globalization; @@ -66,7 +65,7 @@ namespace Jellyfin.LiveTv.Channels { var manager = (ChannelManager)_channelManager; - await manager.RefreshChannels(new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false); + await manager.RefreshChannels(new Progress<double>(), cancellationToken).ConfigureAwait(false); await new ChannelPostScanTask(_channelManager, _logger, _libraryManager).Run(progress, cancellationToken) .ConfigureAwait(false); diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs index e7e927b2d0..39f334184b 100644 --- a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -14,13 +14,13 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; +using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -51,7 +51,6 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; private readonly TimerManager _timerProvider; - private readonly LiveTvManager _liveTvManager; private readonly ITunerHostManager _tunerHostManager; private readonly IFileSystem _fileSystem; @@ -61,6 +60,8 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; private readonly IStreamHelper _streamHelper; + private readonly LiveTvDtoService _tvDtoService; + private readonly IListingsProvider[] _listingsProviders; private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase); @@ -68,7 +69,7 @@ namespace Jellyfin.LiveTv.EmbyTV private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels = new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase); - private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1); private bool _disposed; @@ -78,13 +79,14 @@ namespace Jellyfin.LiveTv.EmbyTV ILogger<EmbyTV> logger, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, - ILiveTvManager liveTvManager, ITunerHostManager tunerHostManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, - IMediaEncoder mediaEncoder) + IMediaEncoder mediaEncoder, + LiveTvDtoService tvDtoService, + IEnumerable<IListingsProvider> listingsProviders) { Current = this; @@ -96,10 +98,11 @@ namespace Jellyfin.LiveTv.EmbyTV _libraryMonitor = libraryMonitor; _providerManager = providerManager; _mediaEncoder = mediaEncoder; - _liveTvManager = (LiveTvManager)liveTvManager; + _tvDtoService = tvDtoService; _tunerHostManager = tunerHostManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; + _listingsProviders = listingsProviders.ToArray(); _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); @@ -257,7 +260,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (requiresRefresh) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } } @@ -937,7 +940,7 @@ namespace Jellyfin.LiveTv.EmbyTV return _config.GetLiveTvConfiguration().ListingProviders .Select(i => { - var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + var provider = _listingsProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); return provider is null ? null : new Tuple<IListingsProvider, ListingsProviderInfo>(provider, i); }) @@ -1181,6 +1184,12 @@ namespace Jellyfin.LiveTv.EmbyTV return Path.Combine(recordPath, recordingFileName); } + private BaseItem GetLiveTvChannel(TimerInfo timer) + { + var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId); + return _libraryManager.GetItemById(internalChannelId); + } + private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) { ArgumentNullException.ThrowIfNull(timer); @@ -1206,7 +1215,7 @@ namespace Jellyfin.LiveTv.EmbyTV var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); + var channelItem = GetLiveTvChannel(timer); string liveStreamId = null; RecordingStatus recordingStatus; @@ -1444,9 +1453,7 @@ namespace Jellyfin.LiveTv.EmbyTV return; } - await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); - - try + using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false)) { if (_disposed) { @@ -1499,10 +1506,6 @@ namespace Jellyfin.LiveTv.EmbyTV } } } - finally - { - _recordingDeleteSemaphore.Release(); - } } private void DeleteLibraryItemsForTimers(List<TimerInfo> timers) @@ -2089,7 +2092,7 @@ namespace Jellyfin.LiveTv.EmbyTV { var query = new InternalItemsQuery { - ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, + ItemIds = [_tvDtoService.GetInternalProgramId(programId)], Limit = 1, DtoOptions = new DtoOptions() }; @@ -2119,7 +2122,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (!string.IsNullOrWhiteSpace(channelId)) { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)]; } return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault(); @@ -2155,7 +2158,7 @@ namespace Jellyfin.LiveTv.EmbyTV private void HandleDuplicateShowIds(List<TimerInfo> timers) { // sort showings by HD channels first, then by startDate, record earliest showing possible - foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) + foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1)) { timer.Status = RecordingStatus.Cancelled; _timerProvider.Update(timer); @@ -2305,7 +2308,7 @@ namespace Jellyfin.LiveTv.EmbyTV if (!seriesTimer.RecordAnyChannel) { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; + query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)]; } var tempChannelCache = new Dictionary<Guid, LiveTvChannel>(); diff --git a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs deleted file mode 100644 index e750c05ac8..0000000000 --- a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading.Tasks; -using MediaBrowser.Controller.Plugins; - -namespace Jellyfin.LiveTv.EmbyTV -{ - public sealed class EntryPoint : IServerEntryPoint - { - /// <inheritdoc /> - public Task RunAsync() - { - return EmbyTV.Current.Start(); - } - - /// <inheritdoc /> - public void Dispose() - { - } - } -} diff --git a/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs new file mode 100644 index 0000000000..dc15d53ffa --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/LiveTvHost.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.LiveTv; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.LiveTv.EmbyTV; + +/// <summary> +/// <see cref="IHostedService"/> responsible for initializing Live TV. +/// </summary> +public sealed class LiveTvHost : IHostedService +{ + private readonly EmbyTV _service; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvHost"/> class. + /// </summary> + /// <param name="services">The available <see cref="ILiveTvService"/>s.</param> + public LiveTvHost(IEnumerable<ILiveTvService> services) + { + _service = services.OfType<EmbyTV>().First(); + } + + /// <inheritdoc /> + public Task StartAsync(CancellationToken cancellationToken) => _service.Start(); + + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs index 5490547ec3..a07325ad18 100644 --- a/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs +++ b/src/Jellyfin.LiveTv/Extensions/LiveTvServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ using Jellyfin.LiveTv.Channels; +using Jellyfin.LiveTv.Guide; +using Jellyfin.LiveTv.Listings; using Jellyfin.LiveTv.TunerHosts; using Jellyfin.LiveTv.TunerHosts.HdHomerun; using MediaBrowser.Controller.Channels; @@ -24,8 +26,12 @@ public static class LiveTvServiceCollectionExtensions services.AddSingleton<IChannelManager, ChannelManager>(); services.AddSingleton<IStreamHelper, StreamHelper>(); services.AddSingleton<ITunerHostManager, TunerHostManager>(); + services.AddSingleton<IGuideManager, GuideManager>(); + services.AddSingleton<ILiveTvService, EmbyTV.EmbyTV>(); services.AddSingleton<ITunerHost, HdHomerunHost>(); services.AddSingleton<ITunerHost, M3UTunerHost>(); + services.AddSingleton<IListingsProvider, SchedulesDirect>(); + services.AddSingleton<IListingsProvider, XmlTvListingsProvider>(); } } diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs new file mode 100644 index 0000000000..394fbbaeab --- /dev/null +++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs @@ -0,0 +1,707 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using Jellyfin.LiveTv.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Guide; + +/// <inheritdoc /> +public class GuideManager : IGuideManager +{ + private const int MaxGuideDays = 14; + private const string EtagKey = "ProgramEtag"; + private const string ExternalServiceTag = "ExternalServiceId"; + + private readonly ILogger<GuideManager> _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IItemRepository _itemRepo; + private readonly ILibraryManager _libraryManager; + private readonly ILiveTvManager _liveTvManager; + private readonly ITunerHostManager _tunerHostManager; + private readonly LiveTvDtoService _tvDtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="GuideManager"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param> + /// <param name="config">The <see cref="IConfigurationManager"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> + /// <param name="itemRepo">The <see cref="IItemRepository"/>.</param> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param> + /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param> + /// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param> + public GuideManager( + ILogger<GuideManager> logger, + IConfigurationManager config, + IFileSystem fileSystem, + IItemRepository itemRepo, + ILibraryManager libraryManager, + ILiveTvManager liveTvManager, + ITunerHostManager tunerHostManager, + LiveTvDtoService tvDtoService) + { + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _itemRepo = itemRepo; + _libraryManager = libraryManager; + _liveTvManager = liveTvManager; + _tunerHostManager = tunerHostManager; + _tvDtoService = tvDtoService; + } + + /// <inheritdoc /> + public GuideInfo GetGuideInfo() + { + var startDate = DateTime.UtcNow; + var endDate = startDate.AddDays(GetGuideDays()); + + return new GuideInfo + { + StartDate = startDate, + EndDate = endDate + }; + } + + /// <inheritdoc /> + public async Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(progress); + + await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); + + await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); + + var numComplete = 0; + double progressPerService = _liveTvManager.Services.Count == 0 + ? 0 + : 1.0 / _liveTvManager.Services.Count; + + var newChannelIdList = new List<Guid>(); + var newProgramIdList = new List<Guid>(); + + var cleanDatabase = true; + + foreach (var service in _liveTvManager.Services) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogDebug("Refreshing guide from {Name}", service.Name); + + try + { + var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService)); + + var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + + newChannelIdList.AddRange(idList.Item1); + newProgramIdList.AddRange(idList.Item2); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + cleanDatabase = false; + _logger.LogError(ex, "Error refreshing channels for service"); + } + + numComplete++; + double percent = numComplete; + percent /= _liveTvManager.Services.Count; + + progress.Report(100 * percent); + } + + if (cleanDatabase) + { + CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken); + CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken); + } + + var coreService = _liveTvManager.Services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); + if (coreService is not null) + { + await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); + await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); + } + + progress.Report(100); + } + + private double GetGuideDays() + { + var config = _config.GetLiveTvConfiguration(); + + return config.GuideDays.HasValue + ? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays) + : 7; + } + + private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken) + { + progress.Report(10); + + var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) + .Select(i => new Tuple<string, ChannelInfo>(service.Name, i)) + .ToList(); + + var list = new List<LiveTvChannel>(); + + var numComplete = 0; + var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken); + + foreach (var channelInfo in allChannelsList) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); + + list.Add(item); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); + } + + numComplete++; + double percent = numComplete; + percent /= allChannelsList.Count; + + progress.Report((5 * percent) + 10); + } + + progress.Report(15); + + numComplete = 0; + var programs = new List<Guid>(); + var channels = new List<Guid>(); + + var guideDays = GetGuideDays(); + + _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + + foreach (var currentChannel in list) + { + cancellationToken.ThrowIfCancellationRequested(); + channels.Add(currentChannel.Id); + + try + { + var start = DateTime.UtcNow.AddHours(-1); + var end = start.AddDays(guideDays); + + var isMovie = false; + var isSports = false; + var isNews = false; + var isKids = false; + var isSeries = false; + + var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); + + var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.LiveTvProgram], + ChannelIds = [currentChannel.Id], + DtoOptions = new DtoOptions(true) + }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); + + var newPrograms = new List<LiveTvProgram>(); + var updatedPrograms = new List<BaseItem>(); + + foreach (var program in channelPrograms) + { + var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel); + if (isNew) + { + newPrograms.Add(programItem); + } + else if (isUpdated) + { + updatedPrograms.Add(programItem); + } + + programs.Add(programItem.Id); + + isMovie |= program.IsMovie; + isSeries |= program.IsSeries; + isSports |= program.IsSports; + isNews |= program.IsNews; + isKids |= program.IsKids; + } + + _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + + if (newPrograms.Count > 0) + { + _libraryManager.CreateItems(newPrograms, null, cancellationToken); + } + + if (updatedPrograms.Count > 0) + { + await _libraryManager.UpdateItemsAsync( + updatedPrograms, + currentChannel, + ItemUpdateType.MetadataImport, + cancellationToken).ConfigureAwait(false); + } + + currentChannel.IsMovie = isMovie; + currentChannel.IsNews = isNews; + currentChannel.IsSports = isSports; + currentChannel.IsSeries = isSeries; + + if (isKids) + { + currentChannel.AddTag("Kids"); + } + + await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await currentChannel.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); + } + + numComplete++; + double percent = numComplete / (double)allChannelsList.Count; + + progress.Report((85 * percent) + 15); + } + + progress.Report(100); + return new Tuple<List<Guid>, List<Guid>>(channels, programs); + } + + private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) + { + var list = _itemRepo.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = validTypes, + DtoOptions = new DtoOptions(false) + }); + + var numComplete = 0; + + foreach (var itemId in list) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (itemId.IsEmpty()) + { + // Somehow some invalid data got into the db. It probably predates the boundary checking + continue; + } + + if (!currentIdList.Contains(itemId)) + { + var item = _libraryManager.GetItemById(itemId); + + if (item is not null) + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + false); + } + } + + numComplete++; + double percent = numComplete / (double)list.Count; + + progress.Report(100 * percent); + } + } + + private async Task<LiveTvChannel> GetChannel( + ChannelInfo channelInfo, + string serviceName, + BaseItem parentFolder, + CancellationToken cancellationToken) + { + var parentFolderId = parentFolder.Id; + var isNew = false; + var forceUpdate = false; + + var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); + + if (_libraryManager.GetItemById(id) is not LiveTvChannel item) + { + item = new LiveTvChannel + { + Name = channelInfo.Name, + Id = id, + DateCreated = DateTime.UtcNow + }; + + isNew = true; + } + + if (channelInfo.Tags is not null) + { + if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) + { + isNew = true; + } + + item.Tags = channelInfo.Tags; + } + + if (!item.ParentId.Equals(parentFolderId)) + { + isNew = true; + } + + item.ParentId = parentFolderId; + + item.ChannelType = channelInfo.ChannelType; + item.ServiceName = serviceName; + + if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) + { + 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)) + { + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } + } + + if (isNew) + { + _libraryManager.CreateItem(item, parentFolder); + } + else if (forceUpdate) + { + await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + return item; + } + + private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram( + ProgramInfo info, + Dictionary<Guid, LiveTvProgram> allExistingPrograms, + LiveTvChannel channel) + { + var id = _tvDtoService.GetInternalProgramId(info.Id); + + var isNew = false; + var forceUpdate = false; + + if (!allExistingPrograms.TryGetValue(id, out var item)) + { + isNew = true; + item = new LiveTvProgram + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + if (!string.IsNullOrEmpty(info.Etag)) + { + item.SetProviderId(EtagKey, info.Etag); + } + } + + if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) + { + item.ShowId = info.ShowId; + forceUpdate = true; + } + + var seriesId = info.SeriesId; + + if (!item.ParentId.Equals(channel.Id)) + { + forceUpdate = true; + } + + item.ParentId = channel.Id; + + item.Audio = info.Audio; + item.ChannelId = channel.Id; + item.CommunityRating ??= info.CommunityRating; + if ((item.CommunityRating ?? 0).Equals(0)) + { + item.CommunityRating = null; + } + + item.EpisodeTitle = info.EpisodeTitle; + item.ExternalId = info.Id; + + if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalSeriesId = seriesId; + + var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); + + if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) + { + item.SeriesName = info.Name; + } + + var tags = new List<string>(); + if (info.IsLive) + { + 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"); + } + + item.Tags = tags.ToArray(); + + item.Genres = info.Genres.ToArray(); + + if (info.IsHD ?? false) + { + item.Width = 1280; + item.Height = 720; + } + + item.IsMovie = info.IsMovie; + item.IsRepeat = info.IsRepeat; + + if (item.IsSeries != isSeries) + { + forceUpdate = true; + } + + item.IsSeries = isSeries; + + item.Name = info.Name; + item.OfficialRating ??= info.OfficialRating; + item.Overview ??= info.Overview; + item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; + item.ProviderIds = info.ProviderIds; + + foreach (var providerId in info.SeriesProviderIds) + { + info.ProviderIds["Series" + providerId.Key] = providerId.Value; + } + + if (item.StartDate != info.StartDate) + { + forceUpdate = true; + } + + item.StartDate = info.StartDate; + + if (item.EndDate != info.EndDate) + { + forceUpdate = true; + } + + item.EndDate = info.EndDate; + + item.ProductionYear = info.ProductionYear; + + if (!isSeries || info.IsRepeat) + { + item.PremiereDate = info.OriginalAirDate; + } + + item.IndexNumber = info.EpisodeNumber; + item.ParentIndexNumber = info.SeasonNumber; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(info.ImagePath)) + { + 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); + } + } + + if (!item.HasImage(ImageType.Thumb)) + { + if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ThumbImageUrl, + Type = ImageType.Thumb + }, + 0); + } + } + + if (!item.HasImage(ImageType.Logo)) + { + if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.LogoImageUrl, + Type = ImageType.Logo + }, + 0); + } + } + + if (!item.HasImage(ImageType.Backdrop)) + { + if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.BackdropImageUrl, + Type = ImageType.Backdrop + }, + 0); + } + } + + var isUpdated = false; + if (isNew) + { + } + else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + { + isUpdated = true; + } + else + { + var etag = info.Etag; + + if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) + { + item.SetProviderId(EtagKey, etag); + isUpdated = true; + } + } + + if (isNew || isUpdated) + { + item.OnMetadataChanged(); + } + + return (item, isNew, isUpdated); + } +} diff --git a/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs new file mode 100644 index 0000000000..a9fde08501 --- /dev/null +++ b/src/Jellyfin.LiveTv/Guide/RefreshGuideScheduledTask.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.LiveTv.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Tasks; + +namespace Jellyfin.LiveTv.Guide; + +/// <summary> +/// The "Refresh Guide" scheduled task. +/// </summary> +public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask +{ + private readonly ILiveTvManager _liveTvManager; + private readonly IGuideManager _guideManager; + private readonly IConfigurationManager _config; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class. + /// </summary> + /// <param name="liveTvManager">The live tv manager.</param> + /// <param name="guideManager">The guide manager.</param> + /// <param name="config">The configuration manager.</param> + public RefreshGuideScheduledTask( + ILiveTvManager liveTvManager, + IGuideManager guideManager, + IConfigurationManager config) + { + _liveTvManager = liveTvManager; + _guideManager = guideManager; + _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"; + + /// <inheritdoc /> + public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <inheritdoc /> + public string Key => "RefreshGuide"; + + /// <inheritdoc /> + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + => _guideManager.RefreshGuide(progress, cancellationToken); + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } + }; + } +} diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index 5a826a1da0..c58889740a 100644 --- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> @@ -11,6 +11,7 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="Jellyfin.XmlTv" /> <PackageReference Include="System.Linq.Async" /> </ItemGroup> diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs index 3b20cd160b..c7a57859e8 100644 --- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -16,6 +16,7 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; @@ -35,7 +36,7 @@ namespace Jellyfin.LiveTv.Listings private readonly ILogger<SchedulesDirect> _logger; private readonly IHttpClientFactory _httpClientFactory; - private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); + private readonly AsyncNonKeyedLocker _tokenLock = new(1); private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; @@ -105,8 +106,7 @@ namespace Jellyfin.LiveTv.Listings using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); options.Content = JsonContent.Create(requestList, options: _jsonOptions); options.Headers.TryAddWithoutValidation("token", token); - using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - var dailySchedules = await response.Content.ReadFromJsonAsync<IReadOnlyList<DayDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var dailySchedules = await Request<IReadOnlyList<DayDto>>(options, true, info, cancellationToken).ConfigureAwait(false); if (dailySchedules is null) { return Array.Empty<ProgramInfo>(); @@ -120,8 +120,8 @@ namespace Jellyfin.LiveTv.Listings var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); - using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); - var programDetails = await innerResponse.Content.ReadFromJsonAsync<IReadOnlyList<ProgramDetailsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, cancellationToken) + .ConfigureAwait(false); if (programDetails is null) { return Array.Empty<ProgramInfo>(); @@ -471,16 +471,13 @@ namespace Jellyfin.LiveTv.Listings str.Length--; str.Append(']'); - using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") - { - Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) - }; + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs"); message.Headers.TryAddWithoutValidation("token", token); + message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json); try { - using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); - return await innerResponse2.Content.ReadFromJsonAsync<IReadOnlyList<ShowImagesDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); + return await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -506,8 +503,7 @@ namespace Jellyfin.LiveTv.Listings try { - using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); - var root = await httpResponse.Content.ReadFromJsonAsync<IReadOnlyList<HeadendsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false); if (root is not null) { foreach (HeadendsDto headend in root) @@ -573,60 +569,64 @@ namespace Jellyfin.LiveTv.Listings } } - await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); - savedToken.Name = result; - savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); - return result; - } - catch (HttpRequestException ex) + using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false)) { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + try { - _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; + var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); + savedToken.Name = result; + savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); + return result; } + catch (HttpRequestException ex) + { + if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } - throw; - } - finally - { - _tokenSemaphore.Release(); + throw; + } } } - private async Task<HttpResponseMessage> Send( - HttpRequestMessage options, + private async Task<T> Request<T>( + HttpRequestMessage message, bool enableRetry, ListingsProviderInfo providerInfo, CancellationToken cancellationToken, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { - var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(message, completionOption, cancellationToken) + .ConfigureAwait(false); if (response.IsSuccessStatusCode) { - return response; + return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false); } - // Response is automatically disposed in the calling function, - // so dispose manually if not returning. -#pragma warning disable IDISP016, IDISP017 - response.Dispose(); if (!enableRetry || (int)response.StatusCode >= 500) { + _logger.LogError( + "Request to {Url} failed with response {Response}", + message.RequestUri, + await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + throw new HttpRequestException( string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), null, response.StatusCode); } -#pragma warning restore IDISP016, IDISP017 _tokens.Clear(); - options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); - return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); + using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri); + retryMessage.Content = message.Content; + retryMessage.Headers.TryAddWithoutValidation( + "token", + await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + + return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false); } private async Task<string> GetTokenInternal( @@ -643,9 +643,7 @@ namespace Jellyfin.LiveTv.Listings string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); - using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - var root = await response.Content.ReadFromJsonAsync<TokenDto>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false); if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) { _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); @@ -662,11 +660,21 @@ namespace Jellyfin.LiveTv.Listings ArgumentException.ThrowIfNullOrEmpty(token); ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - _logger.LogInformation("Adding new LineUp "); + _logger.LogInformation("Adding new lineup {Id}", info.ListingsId); - 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); + using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); + message.Headers.TryAddWithoutValidation("token", token); + + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "Error adding lineup to account: {Response}", + await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + } } private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) @@ -684,9 +692,7 @@ namespace Jellyfin.LiveTv.Listings try { - using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - httpResponse.EnsureSuccessStatusCode(); - var root = await httpResponse.Content.ReadFromJsonAsync<LineupsDto>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request<LineupsDto>(options, false, null, cancellationToken).ConfigureAwait(false); return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; } catch (HttpRequestException ex) @@ -739,8 +745,7 @@ namespace Jellyfin.LiveTv.Listings 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); - var root = await httpResponse.Content.ReadFromJsonAsync<ChannelDto>(_jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await Request<ChannelDto>(options, true, info, cancellationToken).ConfigureAwait(false); if (root is null) { return new List<ChannelInfo>(); @@ -801,7 +806,7 @@ namespace Jellyfin.LiveTv.Listings if (disposing) { - _tokenSemaphore?.Dispose(); + _tokenLock?.Dispose(); } _disposed = true; diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index bada4249aa..ef5283b980 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -12,23 +12,19 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; -using Jellyfin.Extensions; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Guide; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Sorting; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; @@ -41,56 +37,49 @@ namespace Jellyfin.LiveTv /// </summary> public class LiveTvManager : ILiveTvManager { - private const int MaxGuideDays = 14; - private const string ExternalServiceTag = "ExternalServiceId"; - - private const string EtagKey = "ProgramEtag"; - private readonly IServerConfigurationManager _config; 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 ILocalizationManager _localization; - private readonly IFileSystem _fileSystem; private readonly IChannelManager _channelManager; private readonly LiveTvDtoService _tvDtoService; - private readonly ITunerHostManager _tunerHostManager; - - private ILiveTvService[] _services = Array.Empty<ILiveTvService>(); - private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>(); + private readonly ILiveTvService[] _services; + private readonly IListingsProvider[] _listingProviders; public LiveTvManager( IServerConfigurationManager config, ILogger<LiveTvManager> logger, - IItemRepository itemRepo, IUserDataManager userDataManager, IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, ITaskManager taskManager, ILocalizationManager localization, - IFileSystem fileSystem, IChannelManager channelManager, LiveTvDtoService liveTvDtoService, - ITunerHostManager tunerHostManager) + IEnumerable<ILiveTvService> services, + IEnumerable<IListingsProvider> listingProviders) { _config = config; _logger = logger; - _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; _taskManager = taskManager; _localization = localization; - _fileSystem = fileSystem; _dtoService = dtoService; _userDataManager = userDataManager; _channelManager = channelManager; _tvDtoService = liveTvDtoService; - _tunerHostManager = tunerHostManager; + _services = services.ToArray(); + _listingProviders = listingProviders.ToArray(); + + var defaultService = _services.OfType<EmbyTV.EmbyTV>().First(); + defaultService.TimerCreated += OnEmbyTvTimerCreated; + defaultService.TimerCancelled += OnEmbyTvTimerCancelled; } public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; @@ -114,23 +103,6 @@ namespace Jellyfin.LiveTv return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); } - /// <inheritdoc /> - public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<IListingsProvider> listingProviders) - { - _services = services.ToArray(); - - _listingProviders = listingProviders.ToArray(); - - foreach (var service in _services) - { - if (service is EmbyTV.EmbyTV embyTv) - { - embyTv.TimerCreated += OnEmbyTvTimerCreated; - embyTv.TimerCancelled += OnEmbyTvTimerCancelled; - } - } - } - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs<string> e) { var timerId = e.Argument; @@ -151,7 +123,7 @@ namespace Jellyfin.LiveTv public QueryResult<BaseItem> GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) { - var user = query.UserId.IsEmpty() + var user = query.UserId.Equals(default) ? null : _userManager.GetUserById(query.UserId); @@ -401,355 +373,6 @@ namespace Jellyfin.LiveTv } } - private async Task<LiveTvChannel> GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) - { - var parentFolderId = parentFolder.Id; - var isNew = false; - var forceUpdate = false; - - var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); - - var item = _libraryManager.GetItemById(id) as LiveTvChannel; - - if (item is null) - { - item = new LiveTvChannel - { - Name = channelInfo.Name, - Id = id, - DateCreated = DateTime.UtcNow - }; - - isNew = true; - } - - if (channelInfo.Tags is not null) - { - if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) - { - isNew = true; - } - - item.Tags = channelInfo.Tags; - } - - if (!item.ParentId.Equals(parentFolderId)) - { - isNew = true; - } - - item.ParentId = parentFolderId; - - item.ChannelType = channelInfo.ChannelType; - item.ServiceName = serviceName; - - if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) - { - 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)) - { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } - } - - if (isNew) - { - _libraryManager.CreateItem(item, parentFolder); - } - else if (forceUpdate) - { - await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - } - - return item; - } - - private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel) - { - var id = _tvDtoService.GetInternalProgramId(info.Id); - - var isNew = false; - var forceUpdate = false; - - if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item)) - { - isNew = true; - item = new LiveTvProgram - { - Name = info.Name, - Id = id, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - - if (!string.IsNullOrEmpty(info.Etag)) - { - item.SetProviderId(EtagKey, info.Etag); - } - } - - if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) - { - item.ShowId = info.ShowId; - forceUpdate = true; - } - - var seriesId = info.SeriesId; - - if (!item.ParentId.Equals(channel.Id)) - { - forceUpdate = true; - } - - item.ParentId = channel.Id; - - item.Audio = info.Audio; - item.ChannelId = channel.Id; - item.CommunityRating ??= info.CommunityRating; - if ((item.CommunityRating ?? 0).Equals(0)) - { - item.CommunityRating = null; - } - - item.EpisodeTitle = info.EpisodeTitle; - item.ExternalId = info.Id; - - if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalSeriesId = seriesId; - - var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); - - if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) - { - item.SeriesName = info.Name; - } - - var tags = new List<string>(); - if (info.IsLive) - { - 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"); - } - - item.Tags = tags.ToArray(); - - item.Genres = info.Genres.ToArray(); - - if (info.IsHD ?? false) - { - item.Width = 1280; - item.Height = 720; - } - - item.IsMovie = info.IsMovie; - item.IsRepeat = info.IsRepeat; - - if (item.IsSeries != isSeries) - { - forceUpdate = true; - } - - item.IsSeries = isSeries; - - item.Name = info.Name; - item.OfficialRating ??= info.OfficialRating; - item.Overview ??= info.Overview; - item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; - item.ProviderIds = info.ProviderIds; - - foreach (var providerId in info.SeriesProviderIds) - { - info.ProviderIds["Series" + providerId.Key] = providerId.Value; - } - - if (item.StartDate != info.StartDate) - { - forceUpdate = true; - } - - item.StartDate = info.StartDate; - - if (item.EndDate != info.EndDate) - { - forceUpdate = true; - } - - item.EndDate = info.EndDate; - - item.ProductionYear = info.ProductionYear; - - if (!isSeries || info.IsRepeat) - { - item.PremiereDate = info.OriginalAirDate; - } - - item.IndexNumber = info.EpisodeNumber; - item.ParentIndexNumber = info.SeasonNumber; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(info.ImagePath)) - { - 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); - } - } - - if (!item.HasImage(ImageType.Thumb)) - { - if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - }, - 0); - } - } - - if (!item.HasImage(ImageType.Logo)) - { - if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - }, - 0); - } - } - - if (!item.HasImage(ImageType.Backdrop)) - { - if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - }, - 0); - } - } - - var isUpdated = false; - if (isNew) - { - } - else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) - { - isUpdated = true; - } - else - { - var etag = info.Etag; - - if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) - { - item.SetProviderId(EtagKey, etag); - isUpdated = true; - } - } - - if (isNew || isUpdated) - { - item.OnMetadataChanged(); - } - - return (item, isNew, isUpdated); - } - public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null) { var program = _libraryManager.GetItemById(id); @@ -1001,293 +624,6 @@ namespace Jellyfin.LiveTv } } - internal Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken) - { - return RefreshChannelsInternal(progress, cancellationToken); - } - - private async Task RefreshChannelsInternal(IProgress<double> progress, CancellationToken cancellationToken) - { - await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); - - await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); - - var numComplete = 0; - double progressPerService = _services.Length == 0 - ? 0 - : 1.0 / _services.Length; - - var newChannelIdList = new List<Guid>(); - var newProgramIdList = new List<Guid>(); - - var cleanDatabase = true; - - foreach (var service in _services) - { - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogDebug("Refreshing guide from {Name}", service.Name); - - try - { - var innerProgress = new ActionableProgress<double>(); - innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); - - var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); - - newChannelIdList.AddRange(idList.Item1); - newProgramIdList.AddRange(idList.Item2); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - cleanDatabase = false; - _logger.LogError(ex, "Error refreshing channels for service"); - } - - numComplete++; - double percent = numComplete; - percent /= _services.Length; - - progress.Report(100 * percent); - } - - if (cleanDatabase) - { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); - } - - var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); - - if (coreService is not null) - { - await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); - await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); - } - - // Load these now which will prefetch metadata - var dtoOptions = new DtoOptions(); - var fields = dtoOptions.Fields.ToList(); - dtoOptions.Fields = fields.ToArray(); - - progress.Report(100); - } - - private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress<double> progress, CancellationToken cancellationToken) - { - progress.Report(10); - - var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) - .Select(i => new Tuple<string, ChannelInfo>(service.Name, i)) - .ToList(); - - var list = new List<LiveTvChannel>(); - - var numComplete = 0; - var parentFolder = GetInternalLiveTvFolder(cancellationToken); - - foreach (var channelInfo in allChannelsList) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); - - list.Add(item); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); - } - - numComplete++; - double percent = numComplete; - percent /= allChannelsList.Count; - - progress.Report((5 * percent) + 10); - } - - progress.Report(15); - - numComplete = 0; - var programs = new List<Guid>(); - var channels = new List<Guid>(); - - var guideDays = GetGuideDays(); - - _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); - - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var currentChannel in list) - { - channels.Add(currentChannel.Id); - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var start = DateTime.UtcNow.AddHours(-1); - var end = start.AddDays(guideDays); - - var isMovie = false; - var isSports = false; - var isNews = false; - var isKids = false; - var iSSeries = false; - - var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); - - var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ChannelIds = new Guid[] { currentChannel.Id }, - DtoOptions = new DtoOptions(true) - }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); - - var newPrograms = new List<LiveTvProgram>(); - var updatedPrograms = new List<BaseItem>(); - - foreach (var program in channelPrograms) - { - var programTuple = GetProgram(program, existingPrograms, currentChannel); - var programItem = programTuple.Item; - - if (programTuple.IsNew) - { - newPrograms.Add(programItem); - } - else if (programTuple.IsUpdated) - { - updatedPrograms.Add(programItem); - } - - programs.Add(programItem.Id); - - isMovie |= program.IsMovie; - iSSeries |= program.IsSeries; - isSports |= program.IsSports; - isNews |= program.IsNews; - isKids |= program.IsKids; - } - - _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); - - if (newPrograms.Count > 0) - { - _libraryManager.CreateItems(newPrograms, null, cancellationToken); - } - - if (updatedPrograms.Count > 0) - { - await _libraryManager.UpdateItemsAsync( - updatedPrograms, - currentChannel, - ItemUpdateType.MetadataImport, - cancellationToken).ConfigureAwait(false); - } - - currentChannel.IsMovie = isMovie; - currentChannel.IsNews = isNews; - currentChannel.IsSports = isSports; - currentChannel.IsSeries = iSSeries; - - if (isKids) - { - currentChannel.AddTag("Kids"); - } - - await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - await currentChannel.RefreshMetadata( - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ForceSave = true - }, - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); - } - - numComplete++; - double percent = numComplete / (double)allChannelsList.Count; - - progress.Report((85 * percent) + 15); - } - - progress.Report(100); - return new Tuple<List<Guid>, List<Guid>>(channels, programs); - } - - private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) - { - var list = _itemRepo.GetItemIdsList(new InternalItemsQuery - { - IncludeItemTypes = validTypes, - DtoOptions = new DtoOptions(false) - }); - - var numComplete = 0; - - foreach (var itemId in list) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (itemId.IsEmpty()) - { - // Somehow some invalid data got into the db. It probably predates the boundary checking - continue; - } - - if (!currentIdList.Contains(itemId)) - { - var item = _libraryManager.GetItemById(itemId); - - if (item is not null) - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false, - DeleteFromExternalProvider = false - }, - false); - } - } - - numComplete++; - double percent = numComplete / (double)list.Count; - - progress.Report(100 * percent); - } - } - - private double GetGuideDays() - { - var config = _config.GetLiveTvConfiguration(); - - if (config.GuideDays.HasValue) - { - return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); - } - - return 7; - } - private async Task<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) { if (user is null) @@ -1505,7 +841,7 @@ namespace Jellyfin.LiveTv public async Task<QueryResult<BaseItemDto>> GetRecordingsAsync(RecordingQuery query, DtoOptions options) { - var user = query.UserId.IsEmpty() + var user = query.UserId.Equals(default) ? null : _userManager.GetUserById(query.UserId); @@ -1819,12 +1155,6 @@ namespace Jellyfin.LiveTv return new QueryResult<SeriesTimerInfoDto>(returnArray); } - public BaseItem GetLiveTvChannel(TimerInfo timer, ILiveTvService service) - { - var internalChannelId = _tvDtoService.GetInternalChannelId(service.Name, timer.ChannelId); - return _libraryManager.GetItemById(internalChannelId); - } - public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user) { var now = DateTime.UtcNow; @@ -2057,18 +1387,6 @@ namespace Jellyfin.LiveTv await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false); } - public GuideInfo GetGuideInfo() - { - var startDate = DateTime.UtcNow; - var endDate = startDate.AddDays(GetGuideDays()); - - return new GuideInfo - { - StartDate = startDate, - EndDate = endDate - }; - } - private LiveTvServiceInfo[] GetServiceInfos() { return Services.Select(GetServiceInfo).ToArray(); @@ -2302,16 +1620,6 @@ namespace Jellyfin.LiveTv return provider.GetChannels(info, cancellationToken); } - public Guid GetInternalChannelId(string serviceName, string externalId) - { - return _tvDtoService.GetInternalChannelId(serviceName, externalId); - } - - public Guid GetInternalProgramId(string externalId) - { - return _tvDtoService.GetInternalProgramId(externalId); - } - /// <inheritdoc /> public Task<BaseItem[]> GetRecordingFoldersAsync(User user) => GetRecordingFoldersAsync(user, false); diff --git a/src/Jellyfin.LiveTv/RecordingNotifier.cs b/src/Jellyfin.LiveTv/RecordingNotifier.cs index 2923948ebf..226d525e71 100644 --- a/src/Jellyfin.LiveTv/RecordingNotifier.cs +++ b/src/Jellyfin.LiveTv/RecordingNotifier.cs @@ -1,7 +1,3 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Linq; using System.Threading; @@ -10,34 +6,44 @@ 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.Hosting; using Microsoft.Extensions.Logging; namespace Jellyfin.LiveTv { - public sealed class RecordingNotifier : IServerEntryPoint + /// <summary> + /// <see cref="IHostedService"/> responsible for notifying users when a LiveTV recording is completed. + /// </summary> + public sealed class RecordingNotifier : IHostedService { - private readonly ILiveTvManager _liveTvManager; + private readonly ILogger<RecordingNotifier> _logger; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; - private readonly ILogger<RecordingNotifier> _logger; + private readonly ILiveTvManager _liveTvManager; + /// <summary> + /// Initializes a new instance of the <see cref="RecordingNotifier"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger"/>.</param> + /// <param name="sessionManager">The <see cref="ISessionManager"/>.</param> + /// <param name="userManager">The <see cref="IUserManager"/>.</param> + /// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param> public RecordingNotifier( + ILogger<RecordingNotifier> logger, ISessionManager sessionManager, IUserManager userManager, - ILogger<RecordingNotifier> logger, ILiveTvManager liveTvManager) { + _logger = logger; _sessionManager = sessionManager; _userManager = userManager; - _logger = logger; _liveTvManager = liveTvManager; } /// <inheritdoc /> - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { _liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled; _liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled; @@ -47,29 +53,35 @@ namespace Jellyfin.LiveTv return Task.CompletedTask; } - private async void OnLiveTvManagerSeriesTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) { - await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - } + _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; + _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; + _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; + _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; - private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e) - { - await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); + return Task.CompletedTask; } - private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) - { - await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false); - private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e) - { - await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); - } + private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerSeriesTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false); + + private async void OnLiveTvManagerTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e) + => await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false); private async Task SendMessage(SessionMessageType name, TimerEventInfo info) { - var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList(); + var users = _userManager.Users + .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) + .Select(i => i.Id) + .ToList(); try { @@ -80,14 +92,5 @@ namespace Jellyfin.LiveTv _logger.LogError(ex, "Error sending message"); } } - - /// <inheritdoc /> - public void Dispose() - { - _liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled; - _liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled; - _liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated; - _liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated; - } } } diff --git a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs deleted file mode 100644 index 18bd61d999..0000000000 --- a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.LiveTv.Configuration; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Tasks; - -namespace Jellyfin.LiveTv -{ - /// <summary> - /// The "Refresh Guide" scheduled task. - /// </summary> - public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask - { - private readonly ILiveTvManager _liveTvManager; - private readonly 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"; - - /// <inheritdoc /> - public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => true; - - /// <inheritdoc /> - public string Key => "RefreshGuide"; - - /// <inheritdoc /> - public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - var manager = (LiveTvManager)_liveTvManager; - - return manager.RefreshChannels(progress, cancellationToken); - } - - /// <inheritdoc /> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new[] - { - // Every so often - new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } - }; - } - } -} diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs index 3e4b0e13fc..60be19c68a 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.LiveTv.Configuration; +using Jellyfin.LiveTv.Guide; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.LiveTv; diff --git a/src/Jellyfin.Networking/ExternalPortForwarding.cs b/src/Jellyfin.Networking/PortForwardingHost.cs index df9e43ca90..d01343624e 100644 --- a/src/Jellyfin.Networking/ExternalPortForwarding.cs +++ b/src/Jellyfin.Networking/PortForwardingHost.cs @@ -1,7 +1,3 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -12,36 +8,34 @@ using System.Threading.Tasks; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Mono.Nat; namespace Jellyfin.Networking; /// <summary> -/// Server entrypoint handling external port forwarding. +/// <see cref="IHostedService"/> responsible for UPnP port forwarding. /// </summary> -public sealed class ExternalPortForwarding : IServerEntryPoint +public sealed class PortForwardingHost : IHostedService, IDisposable { private readonly IServerApplicationHost _appHost; - private readonly ILogger<ExternalPortForwarding> _logger; + private readonly ILogger<PortForwardingHost> _logger; private readonly IServerConfigurationManager _config; + private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new(); - private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new ConcurrentDictionary<IPEndPoint, byte>(); - - private Timer _timer; - private string _configIdentifier; - + private Timer? _timer; + private string? _configIdentifier; private bool _disposed; /// <summary> - /// Initializes a new instance of the <see cref="ExternalPortForwarding"/> class. + /// Initializes a new instance of the <see cref="PortForwardingHost"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="appHost">The application host.</param> /// <param name="config">The configuration manager.</param> - public ExternalPortForwarding( - ILogger<ExternalPortForwarding> logger, + public PortForwardingHost( + ILogger<PortForwardingHost> logger, IServerApplicationHost appHost, IServerConfigurationManager config) { @@ -66,7 +60,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint .ToString(); } - private void OnConfigurationUpdated(object sender, EventArgs e) + private void OnConfigurationUpdated(object? sender, EventArgs e) { var oldConfigIdentifier = _configIdentifier; _configIdentifier = GetConfigIdentifier(); @@ -79,7 +73,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint } /// <inheritdoc /> - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) { Start(); @@ -88,6 +82,14 @@ public sealed class ExternalPortForwarding : IServerEntryPoint return Task.CompletedTask; } + /// <inheritdoc /> + public Task StopAsync(CancellationToken cancellationToken) + { + Stop(); + + return Task.CompletedTask; + } + private void Start() { var config = _config.GetNetworkConfiguration(); @@ -101,7 +103,8 @@ public sealed class ExternalPortForwarding : IServerEntryPoint NatUtility.DeviceFound += OnNatUtilityDeviceFound; NatUtility.StartDiscovery(); - _timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); + _timer?.Dispose(); + _timer = new Timer(_ => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10)); } private void Stop() @@ -112,13 +115,23 @@ public sealed class ExternalPortForwarding : IServerEntryPoint NatUtility.DeviceFound -= OnNatUtilityDeviceFound; _timer?.Dispose(); + _timer = null; } - private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e) + private async void OnNatUtilityDeviceFound(object? sender, DeviceEventArgs e) { + ObjectDisposedException.ThrowIf(_disposed, this); + try { - await CreateRules(e.Device).ConfigureAwait(false); + // 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 + if (!_createdRules.TryAdd(e.Device.DeviceEndpoint, 0)) + { + return; + } + + await Task.WhenAll(CreatePortMaps(e.Device)).ConfigureAwait(false); } catch (Exception ex) { @@ -126,20 +139,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint } } - private Task CreateRules(INatDevice device) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - // 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 - if (!_createdRules.TryAdd(device.DeviceEndpoint, 0)) - { - return Task.CompletedTask; - } - - return Task.WhenAll(CreatePortMaps(device)); - } - private IEnumerable<Task> CreatePortMaps(INatDevice device) { var config = _config.GetNetworkConfiguration(); @@ -185,8 +184,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint _config.ConfigurationUpdated -= OnConfigurationUpdated; - Stop(); - _timer?.Dispose(); _timer = null; diff --git a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs new file mode 100644 index 0000000000..dd84c1a186 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs @@ -0,0 +1,35 @@ +using Jellyfin.Api.Controllers; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers +{ + public class SystemControllerTests + { + [Fact] + public void GetLogFile_FileDoesNotExist_ReturnsNotFound() + { + var mockFileSystem = new Mock<IFileSystem>(); + mockFileSystem + .Setup(fs => fs.GetFiles(It.IsAny<string>(), It.IsAny<bool>())) + .Returns([new() { Name = "file1.txt" }, new() { Name = "file2.txt" }]); + + var controller = new SystemController( + Mock.Of<ILogger<SystemController>>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IServerApplicationPaths>(), + mockFileSystem.Object, + Mock.Of<INetworkManager>(), + Mock.Of<ISystemManager>()); + + var result = controller.GetLogFile("DOES_NOT_EXIST.txt"); + + Assert.IsType<NotFoundObjectResult>(result); + } + } +} |
