diff options
161 files changed, 3971 insertions, 886 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 000000000..dbe78984a --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "7.0.13", + "commands": [ + "dotnet-ef" + ] + } + } +}
\ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8385e84bf..64730e554 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup .NET uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 + uses: github/codeql-action/init@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 + uses: github/codeql-action/autobuild@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 + uses: github/codeql-action/analyze@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index ba7883a73..75b6a73e5 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -17,14 +17,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 + uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 with: token: ${{ secrets.JF_BOT_TOKEN }} comment-id: ${{ github.event.comment.id }} reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 + uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -51,14 +51,14 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 - name: Notify as running id: comment_running - uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 + uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -93,7 +93,7 @@ jobs: exit ${retcode} - name: Notify with result success - uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 + uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 if: ${{ github.event.comment != null && success() }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -108,7 +108,7 @@ jobs: reactions: hooray - name: Notify with result failure - uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 + uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 if: ${{ github.event.comment != null && failure() }} with: token: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 693f98d16..8c463a8fc 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -14,7 +14,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -39,7 +39,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -112,7 +112,7 @@ jobs: direction: last body-includes: openapi-diff-workflow-comment - name: Reply or edit difference comment (changed) - uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 + uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 if: ${{ steps.read-diff.outputs.body != '' }} with: issue-number: ${{ github.event.pull_request.number }} @@ -127,7 +127,7 @@ jobs: </details> - name: Edit difference comment (unchanged) - uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 + uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0 if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/repo-bump-version.yaml b/.github/workflows/repo-bump-version.yaml index 0ba68dda3..e0383afd2 100644 --- a/.github/workflows/repo-bump-version.yaml +++ b/.github/workflows/repo-bump-version.yaml @@ -33,7 +33,7 @@ jobs: yq-version: v4.9.8 - name: Checkout Repository - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ env.TAG_BRANCH }} diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml index 2b1164116..e0789ea58 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -2,7 +2,7 @@ name: Stale Check on: schedule: - - cron: '30 */12 * * *' + - cron: '30 1 * * *' workflow_dispatch: permissions: @@ -19,11 +19,12 @@ jobs: - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} + ascending: true days-before-stale: 120 days-before-pr-stale: -1 days-before-close: 21 days-before-pr-close: -1 - operations-per-run: 75 + operations-per-run: 500 exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed stale-issue-label: stale stale-issue-message: |- diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e3af12a49..dc5f99c0c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -57,6 +57,7 @@ - [hawken93](https://github.com/hawken93) - [HelloWorld017](https://github.com/HelloWorld017) - [ikomhoog](https://github.com/ikomhoog) + - [iwalton3](https://github.com/iwalton3) - [jftuga](https://github.com/jftuga) - [jmshrv](https://github.com/jmshrv) - [joern-h](https://github.com/joern-h) @@ -88,6 +89,7 @@ - [neilsb](https://github.com/neilsb) - [nevado](https://github.com/nevado) - [Nickbert7](https://github.com/Nickbert7) + - [nicknsy](https://github.com/nicknsy) - [nvllsvm](https://github.com/nvllsvm) - [nyanmisaka](https://github.com/nyanmisaka) - [OancaAndrei](https://github.com/OancaAndrei) diff --git a/Directory.Packages.props b/Directory.Packages.props index e26005f8e..0619c4ef9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,23 +17,23 @@ <PackageVersion Include="Diacritics" Version="3.3.18" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> - <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.2" /> + <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.5" /> <PackageVersion Include="FsCheck.Xunit" Version="2.16.6" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0" /> - <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" /> + <PackageVersion Include="IDisposableAnalyzers" Version="4.0.4" /> <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> <PackageVersion Include="libse" Version="3.6.13" /> <PackageVersion Include="LrcParser" Version="2023.524.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="5.0.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.12" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.13" /> <PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.12" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.13" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="7.0.12" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.12" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.12" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="7.0.13" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.13" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.13" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.13" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.13" /> <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> @@ -42,8 +42,8 @@ <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.12" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.13" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.13" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" /> @@ -88,6 +88,6 @@ <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.3" /> <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" /> - <PackageVersion Include="xunit" Version="2.5.2" /> + <PackageVersion Include="xunit" Version="2.5.3" /> </ItemGroup> </Project> diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs index d21cc6913..bb9b8b0fd 100644 --- a/Emby.Dlna/PlayTo/Device.cs +++ b/Emby.Dlna/PlayTo/Device.cs @@ -927,14 +927,11 @@ namespace Emby.Dlna.PlayTo var resElement = container.Element(UPnpNamespaces.Res); - if (resElement is not null) - { - var info = resElement.Attribute(UPnpNamespaces.ProtocolInfo); + var info = resElement?.Attribute(UPnpNamespaces.ProtocolInfo); - if (info is not null && !string.IsNullOrWhiteSpace(info.Value)) - { - return info.Value.Split(':'); - } + if (info is not null && !string.IsNullOrWhiteSpace(info.Value)) + { + return info.Value.Split(':'); } return new string[4]; @@ -1139,7 +1136,6 @@ namespace Emby.Dlna.PlayTo return new Device(deviceProperties, httpClientFactory, logger); } -#nullable enable private static DeviceIcon CreateIcon(XElement element) { ArgumentNullException.ThrowIfNull(element); diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs index ef617422c..b05e0a095 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -39,9 +39,9 @@ namespace Emby.Dlna.PlayTo private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; + private readonly SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1); + private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); private bool _disposed; - private SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1); - private CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder) { diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 2bd089ed8..b63c8f10e 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -318,7 +318,7 @@ namespace Emby.Naming.Common new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), // <!-- foo.E01., foo.e01. --> new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"), - new EpisodeExpression(@"(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true) + new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true) { DateTimeFormats = new[] { @@ -328,7 +328,7 @@ namespace Emby.Naming.Common "yyyy MM dd" } }, - new EpisodeExpression(@"(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true) + new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true) { DateTimeFormats = new[] { @@ -376,7 +376,7 @@ namespace Emby.Naming.Common IsNamed = true, SupportsAbsoluteEpisodeNumbers = false }, - new EpisodeExpression("[\\/._ -]p(?:ar)?t[_. -]()([ivx]+|[0-9]+)([._ -][^\\/]*)$") + new EpisodeExpression(@"[\/._ -]p(?:ar)?t[_. -]()([ivx]+|[0-9]+)([._ -][^\/]*)$") { SupportsAbsoluteEpisodeNumbers = true }, @@ -417,7 +417,7 @@ namespace Emby.Naming.Common }, // "1-12 episode title" - new EpisodeExpression(@"([0-9]+)-([0-9]+)"), + new EpisodeExpression("([0-9]+)-([0-9]+)"), // "01 - blah.avi", "01-blah.avi" new EpisodeExpression(@".*(\\|\/)(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*\s?-\s?[^\\\/]*$") @@ -712,7 +712,7 @@ namespace Emby.Naming.Common // Chapter is often beginning of filename "^(?<chapter>[0-9]+)", // Part if often ending of filename - @"(?<!ch(?:apter) )(?<part>[0-9]+)$", + "(?<!ch(?:apter) )(?<part>[0-9]+)$", // Sometimes named as 0001_005 (chapter_part) "(?<chapter>[0-9]+)_(?<part>[0-9]+)", // Some audiobooks are ripped from cd's, and will be named by disk number. diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 6edfad575..39524be1d 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -10,8 +10,6 @@ namespace Emby.Server.Implementations.AppBase /// </summary> public abstract class BaseApplicationPaths : IApplicationPaths { - private string _dataPath; - /// <summary> /// Initializes a new instance of the <see cref="BaseApplicationPaths"/> class. /// </summary> @@ -33,7 +31,7 @@ namespace Emby.Server.Implementations.AppBase CachePath = cacheDirectoryPath; WebPath = webDirectoryPath; - _dataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; + DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } /// <summary> @@ -55,7 +53,7 @@ namespace Emby.Server.Implementations.AppBase /// Gets the folder path to the data directory. /// </summary> /// <value>The data directory.</value> - public string DataPath => _dataPath; + public string DataPath { get; } /// <inheritdoc /> public string VirtualDataPath => "%AppDataPath%"; diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 3036cbcf7..8279acb05 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -1159,7 +1159,7 @@ namespace Emby.Server.Implementations.Channels if (info.People is not null && info.People.Count > 0) { - _libraryManager.UpdatePeople(item, info.People); + await _libraryManager.UpdatePeopleAsync(item, info.People, cancellationToken).ConfigureAwait(false); } } else if (forceUpdate) diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 77cf4089b..e519364c2 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -3540,10 +3540,7 @@ namespace Emby.Server.Implementations.Data .Append(paramName) .Append("))) OR "); - if (statement is not null) - { - statement.TryBind(paramName, query.PersonIds[i]); - } + statement?.TryBind(paramName, query.PersonIds[i]); } clauseBuilder.Length -= Or.Length; @@ -4382,7 +4379,7 @@ namespace Emby.Server.Implementations.Data foreach (var videoType in query.VideoTypes) { - videoTypes.Add("data like '%\"VideoType\":\"" + videoType.ToString() + "\"%'"); + videoTypes.Add("data like '%\"VideoType\":\"" + videoType + "\"%'"); } whereClauses.Add("(" + string.Join(" OR ", videoTypes) + ")"); diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 6d27703bd..44b97e8b8 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -22,6 +22,7 @@ using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -52,6 +53,7 @@ namespace Emby.Server.Implementations.Dto private readonly Lazy<ILiveTvManager> _livetvManagerFactory; private readonly ILyricManager _lyricManager; + private readonly ITrickplayManager _trickplayManager; public DtoService( ILogger<DtoService> logger, @@ -63,7 +65,8 @@ namespace Emby.Server.Implementations.Dto IApplicationHost appHost, IMediaSourceManager mediaSourceManager, Lazy<ILiveTvManager> livetvManagerFactory, - ILyricManager lyricManager) + ILyricManager lyricManager, + ITrickplayManager trickplayManager) { _logger = logger; _libraryManager = libraryManager; @@ -75,6 +78,7 @@ namespace Emby.Server.Implementations.Dto _mediaSourceManager = mediaSourceManager; _livetvManagerFactory = livetvManagerFactory; _lyricManager = lyricManager; + _trickplayManager = trickplayManager; } private ILiveTvManager LivetvManager => _livetvManagerFactory.Value; @@ -1059,6 +1063,11 @@ namespace Emby.Server.Implementations.Dto dto.Chapters = _itemRepo.GetChapters(item); } + if (options.ContainsField(ItemFields.Trickplay)) + { + dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); + } + if (video.ExtraType.HasValue) { dto.ExtraType = video.ExtraType.Value.ToString(); diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs index 2c03f9ffd..7e4994f1a 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -39,9 +39,9 @@ namespace Emby.Server.Implementations.EntryPoints /// <summary> /// The UDP server. /// </summary> - private List<UdpServer> _udpServers; - private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); - private bool _disposed = false; + private readonly List<UdpServer> _udpServers; + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private bool _disposed; /// <summary> /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class. diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index 7f620d666..f83da566b 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -12,7 +12,6 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Controller.Net.WebSocketMessages.Outbound; using MediaBrowser.Model.Session; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.HttpServer diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index 15b1836eb..e75cab64c 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -210,7 +210,6 @@ namespace Emby.Server.Implementations.IO DisposeTimer(); _disposed = true; - GC.SuppressFinalize(this); } } } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 4178936ce..c380d67db 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.IO } // unc path - if (filePath.StartsWith("\\\\", StringComparison.Ordinal)) + if (filePath.StartsWith(@"\\", StringComparison.Ordinal)) { return filePath; } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index ce8b1f918..4f0983564 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2850,7 +2850,7 @@ namespace Emby.Server.Implementations.Library { var path = Path.Combine(virtualFolderPath, collectionType.ToString().ToLowerInvariant() + ".collection"); - File.WriteAllBytes(path, Array.Empty<byte>()); + await File.WriteAllBytesAsync(path, Array.Empty<byte>()).ConfigureAwait(false); } CollectionFolder.SaveLibraryOptions(virtualFolderPath, options); diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs index b77c6b204..c860391fc 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using System.Linq; using Emby.Naming.Common; diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index e9538a5c9..858c5b281 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var resolver = new Naming.TV.EpisodeResolver(namingOptions); var folderName = System.IO.Path.GetFileName(path); - var testPath = "\\\\test\\" + folderName; + var testPath = @"\\test\" + folderName; var episodeInfo = resolver.Resolve(testPath, true); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index 1721be9e2..ff25ee585 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -17,7 +17,6 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 4f96dde44..8cd0c4ffb 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -28,7 +28,6 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index a8b090635..68383a554 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -44,8 +44,6 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun StopStreaming(socket).GetAwaiter().GetResult(); } } - - GC.SuppressFinalize(this); } public async Task<bool> CheckTunerAvailability(IPAddress remoteIP, int tuner, CancellationToken cancellationToken) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index 613ea117f..db5e81df5 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Net.Http; using System.Threading; @@ -22,7 +21,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index f33ea2fc9..5da33febe 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -124,5 +124,7 @@ "TaskKeyframeExtractorDescription": "Vytahuje klíčové snímky ze souborů videa za účelem vytváření přesnějších seznamů přehrávání HLS. Tento úkol může trvat velmi dlouho.", "TaskKeyframeExtractor": "Vytahovač klíčových snímků", "External": "Externí", - "HearingImpaired": "Sluchově postižení" + "HearingImpaired": "Sluchově postižení", + "TaskRefreshTrickplayImages": "Generovat obrázky pro Trickplay", + "TaskRefreshTrickplayImagesDescription": "Obrázky Trickplay se používají k zobrazení náhledů u videí v knihovnách, kde je to povoleno." } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index e1c3e9de1..229f055aa 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -124,5 +124,7 @@ "TaskKeyframeExtractorDescription": "Extrahiere Keyframes aus Videodateien, um präzisere HLS-Playlisten zu erzeugen. Dieser Vorgang kann sehr lange dauern.", "TaskKeyframeExtractor": "Keyframe Extraktor", "External": "Extern", - "HearingImpaired": "Hörgeschädigt" + "HearingImpaired": "Hörgeschädigt", + "TaskRefreshTrickplayImages": "Trickplay-Bilder generieren", + "TaskRefreshTrickplayImagesDescription": "Erstellt eine Trickplay-Vorschau für Videos in aktivierten Bibliotheken" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 15088384c..496ecabd3 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -112,6 +112,8 @@ "TaskCleanLogsDescription": "Deletes log files that are more than {0} days old.", "TaskRefreshPeople": "Refresh People", "TaskRefreshPeopleDescription": "Updates metadata for actors and directors in your media library.", + "TaskRefreshTrickplayImages": "Generate Trickplay Images", + "TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries.", "TaskUpdatePlugins": "Update Plugins", "TaskUpdatePluginsDescription": "Downloads and installs updates for plugins that are configured to update automatically.", "TaskCleanTranscode": "Clean Transcode Directory", diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index 7b059c68e..db6116080 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -4,19 +4,19 @@ "Application": "アプリケーション", "Artists": "アーティスト", "AuthenticationSucceededWithUserName": "{0} 認証に成功しました", - "Books": "ブックス", + "Books": "ブック", "CameraImageUploadedFrom": "新しいカメライメージが {0}からアップロードされました", "Channels": "チャンネル", "ChapterNameValue": "チャプター {0}", "Collections": "コレクション", - "DeviceOfflineWithName": "{0} が切断されました", - "DeviceOnlineWithName": "{0} が接続されました", - "FailedLoginAttemptWithUserName": "ログインを試行しましたが {0} によって失敗しました", + "DeviceOfflineWithName": "{0} が切断しました", + "DeviceOnlineWithName": "{0} が接続しました", + "FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました", "Favorites": "お気に入り", "Folders": "フォルダー", "Genres": "ジャンル", "HeaderAlbumArtists": "アルバムアーティスト", - "HeaderContinueWatching": "続けて見る", + "HeaderContinueWatching": "再生を続ける", "HeaderFavoriteAlbums": "お気に入りのアルバム", "HeaderFavoriteArtists": "お気に入りのアーティスト", "HeaderFavoriteEpisodes": "お気に入りのエピソード", @@ -30,19 +30,19 @@ "ItemAddedWithName": "{0} をライブラリに追加しました", "ItemRemovedWithName": "{0} をライブラリから削除しました", "LabelIpAddressValue": "IPアドレス: {0}", - "LabelRunningTimeValue": "稼働時間: {0}", + "LabelRunningTimeValue": "時間: {0}", "Latest": "最新", - "MessageApplicationUpdated": "Jellyfin Server が更新されました", - "MessageApplicationUpdatedTo": "Jellyfin Server が {0}に更新されました", - "MessageNamedServerConfigurationUpdatedWithValue": "サーバー設定項目の {0} が更新されました", - "MessageServerConfigurationUpdated": "サーバー設定が更新されました", + "MessageApplicationUpdated": "Jellyfin Server を更新しました", + "MessageApplicationUpdatedTo": "Jellyfin Server を {0}に更新しました", + "MessageNamedServerConfigurationUpdatedWithValue": "サーバー設定項目の {0} を更新しました", + "MessageServerConfigurationUpdated": "サーバー設定を更新しました", "MixedContent": "ミックスコンテンツ", "Movies": "映画", "Music": "音楽", "MusicVideos": "ミュージックビデオ", "NameInstallFailed": "{0}のインストールに失敗しました", "NameSeasonNumber": "シーズン {0}", - "NameSeasonUnknown": "不明なシーズン", + "NameSeasonUnknown": "シーズン不明", "NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロード可能です。", "NotificationOptionApplicationUpdateAvailable": "アプリケーションの更新があります", "NotificationOptionApplicationUpdateInstalled": "アプリケーションは最新です", diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index d558cdfe0..82a071309 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -1,7 +1,7 @@ { "ServerNameNeedsToBeRestarted": "{0} ir vajadzīgs restarts", "NotificationOptionTaskFailed": "Plānota uzdevuma kļūme", - "HeaderRecordingGroups": "Ierakstu Grupas", + "HeaderRecordingGroups": "Ierakstu grupas", "UserPolicyUpdatedWithName": "Lietotāju politika atjaunota priekš {0}", "SubtitleDownloadFailureFromForItem": "Subtitru lejupielāde no {0} priekš {1} neizdevās", "NotificationOptionVideoPlaybackStopped": "Video atskaņošana apturēta", @@ -14,13 +14,13 @@ "Photos": "Attēli", "NotificationOptionUserLockedOut": "Lietotājs bloķēts", "LabelRunningTimeValue": "Garums: {0}", - "Inherit": "Mantot", + "Inherit": "Pārmantot", "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", "VersionNumber": "Versija {0}", "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai", "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}", "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}", - "UserPasswordChangedWithName": "Parole nomainīta lietotājam {0}", + "UserPasswordChangedWithName": "Lietotāja {0} parole tika nomainīta", "UserOnlineFromDevice": "{0} ir tiešsaistē no {1}", "UserOfflineFromDevice": "{0} ir atvienojies no {1}", "UserLockedOutWithName": "Lietotājs {0} ir ticis bloķēts", @@ -28,23 +28,23 @@ "UserDeletedWithName": "Lietotājs {0} ir izdzēsts", "UserCreatedWithName": "Lietotājs {0} ir ticis izveidots", "User": "Lietotājs", - "TvShows": "TV Raidījumi", + "TvShows": "TV raidījumi", "Sync": "Sinhronizācija", "System": "Sistēma", "StartupEmbyServerIsLoading": "Jellyfin Serveris lādējas. Lūdzu mēģiniet vēlreiz pēc brīža.", "Songs": "Dziesmas", - "Shows": "Raidījumi", + "Shows": "Šovi", "PluginUpdatedWithName": "{0} tika atjaunots", "PluginUninstalledWithName": "{0} tika noņemts", "PluginInstalledWithName": "{0} tika uzstādīts", "Plugin": "Paplašinājums", - "Playlists": "Atskaņošanas Saraksti", + "Playlists": "Atskaņošanas saraksti", "MixedContent": "Jaukts saturs", - "HomeVideos": "Mājas Video", + "HomeVideos": "Mājas video", "HeaderNextUp": "Nākamais", - "ChapterNameValue": "Nodaļa {0}", + "ChapterNameValue": "{0}. nodaļa", "Application": "Lietotne", - "NotificationOptionServerRestartRequired": "Vajadzīgs servera restarts", + "NotificationOptionServerRestartRequired": "Nepieciešams servera restarts", "NotificationOptionPluginUpdateInstalled": "Paplašinājuma atjauninājums uzstādīts", "NotificationOptionPluginUninstalled": "Paplašinājums noņemts", "NotificationOptionPluginInstalled": "Paplašinājums uzstādīts", @@ -56,14 +56,14 @@ "NotificationOptionApplicationUpdateInstalled": "Lietotnes atjauninājums uzstādīts", "NotificationOptionApplicationUpdateAvailable": "Lietotnes atjauninājums pieejams", "NewVersionIsAvailable": "Lejupielādei ir pieejama jauna Jellyfin Server versija.", - "NameSeasonUnknown": "Nezināma Sezona", - "NameSeasonNumber": "Sezona {0}", + "NameSeasonUnknown": "Nezināma sezona", + "NameSeasonNumber": "{0}. sezona", "NameInstallFailed": "{0} instalācija neizdevās", "MusicVideos": "Mūzikas video", "Music": "Mūzika", "Movies": "Filmas", "MessageServerConfigurationUpdated": "Servera konfigurācija ir tikusi atjaunota", - "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} ir tikusi atjaunota", + "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} tika atjaunota", "MessageApplicationUpdatedTo": "Jellyfin Server ir ticis atjaunots uz {0}", "MessageApplicationUpdated": "Jellyfin Server ir ticis atjaunots", "Latest": "Jaunākais", @@ -71,57 +71,57 @@ "ItemRemovedWithName": "{0} tika noņemts no bibliotēkas", "ItemAddedWithName": "{0} tika pievienots bibliotēkai", "HeaderLiveTV": "Tiešraides TV", - "HeaderContinueWatching": "Turpināt Skatīšanos", - "HeaderAlbumArtists": "Albumu Izpildītāji", + "HeaderContinueWatching": "Turpini skatīties", + "HeaderAlbumArtists": "Albumu izpildītāji", "Genres": "Žanri", "Folders": "Mapes", "Favorites": "Izlase", - "FailedLoginAttemptWithUserName": "Neizdevies pieslēgšanās mēģinājums no {0}", - "DeviceOnlineWithName": "{0} ir pievienojies", - "DeviceOfflineWithName": "{0} ir atvienojies", + "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}", + "DeviceOnlineWithName": "Savienojums ar {0} ir izveidots", + "DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts", "Collections": "Kolekcijas", "Channels": "Kanāli", - "CameraImageUploadedFrom": "Jauns kameras attēls ir ticis augšupielādēts no {0}", + "CameraImageUploadedFrom": "Jauns kameras attēls tika augšupielādēts no {0}", "Books": "Grāmatas", "Artists": "Izpildītāji", "Albums": "Albumi", "ProviderValue": "Provider: {0}", - "HeaderFavoriteSongs": "Dziesmu Favorīti", - "HeaderFavoriteShows": "Raidījumu Favorīti", - "HeaderFavoriteEpisodes": "Episožu Favorīti", - "HeaderFavoriteArtists": "Izpildītāju Favorīti", - "HeaderFavoriteAlbums": "Albumu Favorīti", - "TaskCleanCacheDescription": "Nodzēš keša datnes, kas vairs nav sistēmai vajadzīgas.", - "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus", + "HeaderFavoriteSongs": "Dziesmu izlase", + "HeaderFavoriteShows": "Raidījumu izlase", + "HeaderFavoriteEpisodes": "Sēriju izlase", + "HeaderFavoriteArtists": "Izpildītāju izlase", + "HeaderFavoriteAlbums": "Albumu izlase", + "TaskCleanCacheDescription": "Nodzēš kešatmiņas datnes, kas vairs nav sistēmai vajadzīgas.", + "TaskRefreshChapterImages": "Izvilkt nodaļu attēlus", "TasksApplicationCategory": "Lietotne", "TasksLibraryCategory": "Bibliotēka", "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.", - "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus", + "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošos subtitrus", "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.", - "TaskRefreshChannels": "Atjaunot Kanālus", - "TaskCleanTranscodeDescription": "Izdzēš trans-kodēšanas datnes, kas ir vecākas par vienu dienu.", - "TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi", + "TaskRefreshChannels": "Atjaunot kanālus", + "TaskCleanTranscodeDescription": "Izdzēš transkodēšanas datnes, kas ir senākas par vienu dienu.", + "TaskCleanTranscode": "Iztīrīt transkodēšanas mapi", "TaskUpdatePluginsDescription": "Lejupielādē un uzstāda atjauninājumus paplašinājumiem, kam ir uzstādīta automātiskā atjaunināšana.", - "TaskUpdatePlugins": "Atjaunot Paplašinājumus", + "TaskUpdatePlugins": "Atjaunot paplašinājumus", "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.", - "TaskRefreshPeople": "Atjaunot Cilvēkus", - "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.", - "TaskCleanLogs": "Iztīrīt Logdatņu Mapi", + "TaskRefreshPeople": "Atjaunot cilvēkus", + "TaskCleanLogsDescription": "Nodzēš logdatnes, kas ir senākas par {0} dienām.", + "TaskCleanLogs": "Iztīrīt logdatņu mapi", "TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.", - "TaskRefreshLibrary": "Skenēt Multivides Bibliotēku", + "TaskRefreshLibrary": "Skenēt multivides bibliotēku", "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.", - "TaskCleanCache": "Iztīrīt Kešošanas Mapi", - "TasksChannelsCategory": "Interneta Kanāli", + "TaskCleanCache": "Iztīrīt kešatmiņas mapi", + "TasksChannelsCategory": "Interneta kanāli", "TasksMaintenanceCategory": "Apkope", "Forced": "Piespiedu", "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.", - "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu", + "TaskCleanActivityLog": "Notīrīt darbību žurnālu", "Undefined": "Nenoteikts", "Default": "Noklusējuma", - "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Uzdevum palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.", + "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Šī uzdevuma palaišana pēc bibliotēku skenēšanas vai citām, ar datubāzi saistītām, izmaiņām iespējams uzlabos ātrdarbību.", "TaskOptimizeDatabase": "Optimizēt datubāzi", "External": "Ārējais", "HearingImpaired": "Ar dzirdes traucējumiem", - "TaskKeyframeExtractor": "Atslēgkadru Ekstraktors", + "TaskKeyframeExtractor": "Atslēgkadru ekstraktors", "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs." } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index ac7b92de6..be397f1b8 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -124,5 +124,7 @@ "TaskKeyframeExtractorDescription": "Haalt keyframes uit videobestanden om preciezere HLS-afspeellijsten te maken. Deze taak kan lang duren.", "TaskKeyframeExtractor": "Keyframe-uitpakker", "External": "Extern", - "HearingImpaired": "Slechthorend" + "HearingImpaired": "Slechthorend", + "TaskRefreshTrickplayImages": "Trickplay-afbeeldingen genereren", + "TaskRefreshTrickplayImagesDescription": "Genereert trickplay-afbeeldingen voor video's in bibliotheken waarvoor dit is ingeschakeld." } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index d4c15ac87..bd572b744 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -124,5 +124,7 @@ "External": "Zewnętrzny", "TaskKeyframeExtractorDescription": "Wyodrębnia klatki kluczowe z plików wideo w celu utworzenia bardziej precyzyjnych list odtwarzania HLS. To zadanie może trwać przez długi czas.", "TaskKeyframeExtractor": "Ekstraktor klatek kluczowych", - "HearingImpaired": "Niedosłyszący" + "HearingImpaired": "Niedosłyszący", + "TaskRefreshTrickplayImages": "Generuj obrazy trickplay", + "TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach." } diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/si.json @@ -0,0 +1 @@ +{} diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index c231d76fe..43594a42e 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -124,5 +124,7 @@ "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.", "TaskKeyframeExtractor": "Extraktor kľúčových snímkov", "External": "Externé", - "HearingImpaired": "Sluchovo postihnutí" + "HearingImpaired": "Sluchovo postihnutí", + "TaskRefreshTrickplayImages": "Generovanie obrázkov Trickplay", + "TaskRefreshTrickplayImagesDescription": "Vytvára trickplay náhľady pre videá v povolených knižniciach." } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index 770624a8d..646d7d7a5 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -102,7 +102,7 @@ "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன", "TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.", "TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.", - "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.", + "TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்கும்படி கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்கி நிறுவுகிறது.", "TaskRefreshPeopleDescription": "உங்கள் ஊடக நூலகத்தில் உள்ள நடிகர்கள் மற்றும் இயக்குனர்களுக்கான மீத்தரவை புதுப்பிக்கும்.", "TaskCleanLogsDescription": "{0} நாட்களுக்கு மேல் இருக்கும் பதிவு கோப்புகளை நீக்கும்.", "TaskCleanLogs": "பதிவு அடைவை சுத்தம் செய்யுங்கள்", @@ -123,5 +123,7 @@ "TaskKeyframeExtractorDescription": "மிகவும் துல்லியமான HLS பிளேலிஸ்ட்களை உருவாக்க வீடியோ கோப்புகளிலிருந்து கீஃப்ரேம்களைப் பிரித்தெடுக்கிறது. இந்த பணி நீண்ட காலமாக இருக்கலாம்.", "TaskKeyframeExtractor": "கீஃப்ரேம் எக்ஸ்ட்ராக்டர்", "External": "வெளி", - "HearingImpaired": "செவித்திறன் குறைபாடுடையவர்" + "HearingImpaired": "செவித்திறன் குறைபாடுடையவர்", + "TaskRefreshTrickplayImages": "முன்னோட்ட படங்களை உருவாக்கு", + "TaskRefreshTrickplayImagesDescription": "செயல்பாட்டில் உள்ள தொகுப்புகளுக்கு முன்னோட்ட படங்களை உருவாக்கும்." } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 03265d3fb..b88d4eeaf 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -121,8 +121,10 @@ "Default": "默认", "TaskOptimizeDatabaseDescription": "压缩数据库并优化可用空间,在扫描库或执行其他数据库修改后运行此任务可能会提高性能。", "TaskOptimizeDatabase": "优化数据库", - "TaskKeyframeExtractorDescription": "从视频文件中提取关键帧以创建更准确的HLS播放列表。这项任务可能需要很长时间。", + "TaskKeyframeExtractorDescription": "从视频文件中提取关键帧以创建更准确的 HLS 播放列表。这项任务可能需要很长时间。", "TaskKeyframeExtractor": "关键帧提取器", "External": "外部", - "HearingImpaired": "听力障碍" + "HearingImpaired": "听力障碍", + "TaskRefreshTrickplayImages": "生成时间轴缩略图", + "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成时间轴缩略图。" } diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 505984cfb..2bcd5eab2 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -32,7 +32,7 @@ namespace Emby.Server.Implementations.Net } catch { - socket?.Dispose(); + socket.Dispose(); throw; } @@ -59,7 +59,7 @@ namespace Emby.Server.Implementations.Net } catch { - socket?.Dispose(); + socket.Dispose(); throw; } @@ -110,7 +110,7 @@ namespace Emby.Server.Implementations.Net } catch { - socket?.Dispose(); + socket.Dispose(); throw; } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index 6ad6c4cbd..5d15c3a21 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -115,7 +115,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { try { - previouslyFailedImages = File.ReadAllText(failHistoryPath) + previouslyFailedImages = (await File.ReadAllTextAsync(failHistoryPath, cancellationToken).ConfigureAwait(false)) .Split('|', StringSplitOptions.RemoveEmptyEntries) .ToList(); } @@ -156,7 +156,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } string text = string.Join('|', previouslyFailedImages); - File.WriteAllText(failHistoryPath, text); + await File.WriteAllTextAsync(failHistoryPath, text, cancellationToken).ConfigureAwait(false); } numComplete++; diff --git a/Emby.Server.Implementations/SystemManager.cs b/Emby.Server.Implementations/SystemManager.cs index af66b62e3..2c477218f 100644 --- a/Emby.Server.Implementations/SystemManager.cs +++ b/Emby.Server.Implementations/SystemManager.cs @@ -67,7 +67,8 @@ public class SystemManager : ISystemManager ServerName = _applicationHost.FriendlyName, LocalAddress = _applicationHost.GetSmartApiUrl(request), SupportsLibraryMonitor = true, - PackageName = _startupOptions.PackageName + PackageName = _startupOptions.PackageName, + CastReceiverApplications = _configurationManager.Configuration.CastReceiverApplications }; } diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index 10376ed76..2d806c146 100644 --- a/Emby.Server.Implementations/Udp/UdpServer.cs +++ b/Emby.Server.Implementations/Udp/UdpServer.cs @@ -27,9 +27,9 @@ namespace Emby.Server.Implementations.Udp private readonly byte[] _receiveBuffer = new byte[8192]; - private Socket _udpSocket; - private IPEndPoint _endpoint; - private bool _disposed = false; + private readonly Socket _udpSocket; + private readonly IPEndPoint _endpoint; + private bool _disposed; /// <summary> /// Initializes a new instance of the <see cref="UdpServer" /> class. @@ -130,9 +130,8 @@ namespace Emby.Server.Implementations.Udp return; } - _udpSocket?.Dispose(); - - GC.SuppressFinalize(this); + _udpSocket.Dispose(); + _disposed = true; } } } diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 42c94c29d..38953dc21 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -410,6 +410,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> [HttpGet("Videos/{itemId}/master.m3u8")] @@ -467,7 +468,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true) + [FromQuery] bool enableAdaptiveBitrateStreaming = true, + [FromQuery] bool enableTrickplay = true) { var streamingRequest = new HlsVideoRequestDto { @@ -521,7 +523,8 @@ public class DynamicHlsController : BaseJellyfinApiController VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, - EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming, + EnableTrickplay = enableTrickplay }; return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 46c0a8d52..21941ff94 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -294,8 +294,8 @@ public class LibraryController : BaseJellyfinApiController return new AllThemeMediaResult { - ThemeSongsResult = themeSongs?.Value, - ThemeVideosResult = themeVideos?.Value, + ThemeSongsResult = themeSongs.Value, + ThemeVideosResult = themeVideos.Value, SoundtrackSongsResult = new ThemeMediaResult() }; } @@ -490,7 +490,7 @@ public class LibraryController : BaseJellyfinApiController baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - parent = parent?.GetParent(); + parent = parent.GetParent(); } return baseItemDtos; diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs new file mode 100644 index 000000000..2dc960229 --- /dev/null +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -0,0 +1,101 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Trickplay controller. +/// </summary> +[Route("")] +[Authorize] +public class TrickplayController : BaseJellyfinApiController +{ + private readonly ILibraryManager _libraryManager; + private readonly ITrickplayManager _trickplayManager; + + /// <summary> + /// Initializes a new instance of the <see cref="TrickplayController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/>.</param> + /// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param> + public TrickplayController( + ILibraryManager libraryManager, + ITrickplayManager trickplayManager) + { + _libraryManager = libraryManager; + _trickplayManager = trickplayManager; + } + + /// <summary> + /// Gets an image tiles playlist for trickplay. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="width">The width of a single tile.</param> + /// <param name="mediaSourceId">The media version id, if using an alternate version.</param> + /// <response code="200">Tiles playlist returned.</response> + /// <returns>A <see cref="FileResult"/> containing the trickplay playlist file.</returns> + [HttpGet("Videos/{itemId}/Trickplay/{width}/tiles.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesPlaylistFile] + public async Task<ActionResult> GetTrickplayHlsPlaylist( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int width, + [FromQuery] Guid? mediaSourceId) + { + string? playlist = await _trickplayManager.GetHlsPlaylist(mediaSourceId ?? itemId, width, User.GetToken()).ConfigureAwait(false); + + if (string.IsNullOrEmpty(playlist)) + { + return NotFound(); + } + + return Content(playlist, MimeTypes.GetMimeType("playlist.m3u8"), Encoding.UTF8); + } + + /// <summary> + /// Gets a trickplay tile image. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="width">The width of a single tile.</param> + /// <param name="index">The index of the desired tile.</param> + /// <param name="mediaSourceId">The media version id, if using an alternate version.</param> + /// <response code="200">Tile image returned.</response> + /// <response code="200">Tile image not found at specified index.</response> + /// <returns>A <see cref="FileResult"/> containing the trickplay tiles image.</returns> + [HttpGet("Videos/{itemId}/Trickplay/{width}/{index}.jpg")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public ActionResult GetTrickplayTileImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int width, + [FromRoute, Required] int index, + [FromQuery] Guid? mediaSourceId) + { + var item = _libraryManager.GetItemById(mediaSourceId ?? itemId); + if (item is null) + { + return NotFound(); + } + + var path = _trickplayManager.GetTrickplayTilePath(item, width, index); + if (System.IO.File.Exists(path)) + { + return PhysicalFile(path, MediaTypeNames.Image.Jpeg); + } + + return NotFound(); + } +} diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 276a09f41..24082fcff 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; @@ -19,6 +20,7 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Net; @@ -46,6 +48,7 @@ public class DynamicHlsHelper private readonly ILogger<DynamicHlsHelper> _logger; private readonly IHttpContextAccessor _httpContextAccessor; private readonly EncodingHelper _encodingHelper; + private readonly ITrickplayManager _trickplayManager; /// <summary> /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class. @@ -62,6 +65,7 @@ public class DynamicHlsHelper /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param> /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + /// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param> public DynamicHlsHelper( ILibraryManager libraryManager, IUserManager userManager, @@ -74,7 +78,8 @@ public class DynamicHlsHelper INetworkManager networkManager, ILogger<DynamicHlsHelper> logger, IHttpContextAccessor httpContextAccessor, - EncodingHelper encodingHelper) + EncodingHelper encodingHelper, + ITrickplayManager trickplayManager) { _libraryManager = libraryManager; _userManager = userManager; @@ -88,6 +93,7 @@ public class DynamicHlsHelper _logger = logger; _httpContextAccessor = httpContextAccessor; _encodingHelper = encodingHelper; + _trickplayManager = trickplayManager; } /// <summary> @@ -280,6 +286,13 @@ public class DynamicHlsHelper AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); } + if (!isLiveStream && (state.VideoRequest?.EnableTrickplay ?? false)) + { + var sourceId = Guid.Parse(state.Request.MediaSourceId); + var trickplayResolutions = await _trickplayManager.GetTrickplayResolutions(sourceId).ConfigureAwait(false); + AddTrickplay(state, trickplayResolutions, builder, _httpContextAccessor.HttpContext.User); + } + return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } @@ -509,6 +522,41 @@ public class DynamicHlsHelper } /// <summary> + /// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="trickplayResolutions">Dictionary of widths to corresponding tiles info.</param> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="user">Http user context.</param> + private void AddTrickplay(StreamState state, Dictionary<int, TrickplayInfo> trickplayResolutions, StringBuilder builder, ClaimsPrincipal user) + { + const string playlistFormat = "#EXT-X-IMAGE-STREAM-INF:BANDWIDTH={0},RESOLUTION={1}x{2},CODECS=\"jpeg\",URI=\"{3}\""; + + foreach (var resolution in trickplayResolutions) + { + var width = resolution.Key; + var trickplayInfo = resolution.Value; + + var url = string.Format( + CultureInfo.InvariantCulture, + "Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&api_key={2}", + width.ToString(CultureInfo.InvariantCulture), + state.Request.MediaSourceId, + user.GetToken()); + + builder.AppendFormat( + CultureInfo.InvariantCulture, + playlistFormat, + trickplayInfo.Bandwidth.ToString(CultureInfo.InvariantCulture), + trickplayInfo.Width.ToString(CultureInfo.InvariantCulture), + trickplayInfo.Height.ToString(CultureInfo.InvariantCulture), + url); + + builder.AppendLine(); + } + } + + /// <summary> /// Get the H.26X level of the output video stream. /// </summary> /// <param name="state">StreamState of the current stream.</param> diff --git a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs index 060c14f89..acbb4877d 100644 --- a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs @@ -122,17 +122,17 @@ public class ExceptionMiddleware private static int GetStatusCode(Exception ex) { - switch (ex) + return ex switch { - case ArgumentException _: return StatusCodes.Status400BadRequest; - case AuthenticationException _: return StatusCodes.Status401Unauthorized; - case SecurityException _: return StatusCodes.Status403Forbidden; - case DirectoryNotFoundException _: - case FileNotFoundException _: - case ResourceNotFoundException _: return StatusCodes.Status404NotFound; - case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; - default: return StatusCodes.Status500InternalServerError; - } + ArgumentException => StatusCodes.Status400BadRequest, + AuthenticationException => StatusCodes.Status401Unauthorized, + SecurityException => StatusCodes.Status403Forbidden, + DirectoryNotFoundException => StatusCodes.Status404NotFound, + FileNotFoundException => StatusCodes.Status404NotFound, + ResourceNotFoundException => StatusCodes.Status404NotFound, + MethodNotAllowedException => StatusCodes.Status405MethodNotAllowed, + _ => StatusCodes.Status500InternalServerError + }; } private string NormalizeExceptionMessage(string msg) diff --git a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs index 60c529d4a..8548fec1a 100644 --- a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs @@ -1,4 +1,4 @@ -namespace Jellyfin.Api.Models.StreamingDtos; +namespace Jellyfin.Api.Models.StreamingDtos; /// <summary> /// The video request dto. @@ -15,4 +15,9 @@ public class VideoRequestDto : StreamingRequestDto /// Gets or sets a value indicating whether to enable subtitles in the manifest. /// </summary> public bool EnableSubtitlesInManifest { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable trickplay images. + /// </summary> + public bool EnableTrickplay { get; set; } } diff --git a/Jellyfin.Data/Entities/TrickplayInfo.cs b/Jellyfin.Data/Entities/TrickplayInfo.cs new file mode 100644 index 000000000..64e7da1b5 --- /dev/null +++ b/Jellyfin.Data/Entities/TrickplayInfo.cs @@ -0,0 +1,75 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.Data.Entities; + +/// <summary> +/// An entity representing the metadata for a group of trickplay tiles. +/// </summary> +public class TrickplayInfo +{ + /// <summary> + /// Gets or sets the id of the associated item. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + [JsonIgnore] + public Guid ItemId { get; set; } + + /// <summary> + /// Gets or sets width of an individual thumbnail. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int Width { get; set; } + + /// <summary> + /// Gets or sets height of an individual thumbnail. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int Height { get; set; } + + /// <summary> + /// Gets or sets amount of thumbnails per row. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int TileWidth { get; set; } + + /// <summary> + /// Gets or sets amount of thumbnails per column. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int TileHeight { get; set; } + + /// <summary> + /// Gets or sets total amount of non-black thumbnails. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int ThumbnailCount { get; set; } + + /// <summary> + /// Gets or sets interval in milliseconds between each trickplay thumbnail. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int Interval { get; set; } + + /// <summary> + /// Gets or sets peak bandwith usage in bits per second. + /// </summary> + /// <remarks> + /// Required. + /// </remarks> + public int Bandwidth { get; set; } +} diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index 8e66a3287..ea0de3016 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -288,6 +288,12 @@ namespace Jellyfin.Data.Entities /// </summary> public SyncPlayUserAccessType SyncPlayAccess { get; set; } + /// <summary> + /// Gets or sets the cast receiver id. + /// </summary> + [StringLength(32)] + public string? CastReceiverId { get; set; } + /// <inheritdoc /> [ConcurrencyCheck] public uint RowVersion { get; private set; } diff --git a/Jellyfin.Networking/Extensions/NetworkExtensions.cs b/Jellyfin.Networking/Extensions/NetworkExtensions.cs index 2d921a482..a1e1140f1 100644 --- a/Jellyfin.Networking/Extensions/NetworkExtensions.cs +++ b/Jellyfin.Networking/Extensions/NetworkExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Net; using System.Net.Sockets; using System.Text.RegularExpressions; @@ -204,7 +203,7 @@ public static partial class NetworkExtensions { var ipBlock = splitString.Current; var address = IPAddress.None; - if (negated && ipBlock.StartsWith<char>("!") && IPAddress.TryParse(ipBlock[1..], out var tmpAddress)) + if (negated && ipBlock.StartsWith("!") && IPAddress.TryParse(ipBlock[1..], out var tmpAddress)) { address = tmpAddress; } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs index 2ee5b4e88..3f3a0dec5 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.Threading.Tasks; using Jellyfin.Data.Entities; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Model.Activity; diff --git a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs index 9a473de52..9626817e9 100644 --- a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using Jellyfin.Data.Events; -using Jellyfin.Data.Events.System; +using Jellyfin.Data.Events.System; using Jellyfin.Data.Events.Users; using Jellyfin.Server.Implementations.Events.Consumers.Library; using Jellyfin.Server.Implementations.Events.Consumers.Security; diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 390ed58b3..fa6adb022 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -8,6 +8,10 @@ <!-- Code analysers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="IDisposableAnalyzers"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> diff --git a/Jellyfin.Server.Implementations/JellyfinDbContext.cs b/Jellyfin.Server.Implementations/JellyfinDbContext.cs index 0d91707e3..ea99af004 100644 --- a/Jellyfin.Server.Implementations/JellyfinDbContext.cs +++ b/Jellyfin.Server.Implementations/JellyfinDbContext.cs @@ -78,6 +78,11 @@ public class JellyfinDbContext : DbContext /// </summary> public DbSet<User> Users => Set<User>(); + /// <summary> + /// Gets the <see cref="DbSet{TEntity}"/> containing the trickplay metadata. + /// </summary> + public DbSet<TrickplayInfo> TrickplayInfos => Set<TrickplayInfo>(); + /*public DbSet<Artwork> Artwork => Set<Artwork>(); public DbSet<Book> Books => Set<Book>(); diff --git a/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs new file mode 100644 index 000000000..28baf1992 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.Designer.cs @@ -0,0 +1,681 @@ +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20230626233818_AddTrickplayInfos")] + partial class AddTrickplayInfos + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.7"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs new file mode 100644 index 000000000..76b12de08 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230626233818_AddTrickplayInfos.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class AddTrickplayInfos : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TrickplayInfos", + columns: table => new + { + ItemId = table.Column<Guid>(type: "TEXT", nullable: false), + Width = table.Column<int>(type: "INTEGER", nullable: false), + Height = table.Column<int>(type: "INTEGER", nullable: false), + TileWidth = table.Column<int>(type: "INTEGER", nullable: false), + TileHeight = table.Column<int>(type: "INTEGER", nullable: false), + ThumbnailCount = table.Column<int>(type: "INTEGER", nullable: false), + Interval = table.Column<int>(type: "INTEGER", nullable: false), + Bandwidth = table.Column<int>(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrickplayInfos", x => new { x.ItemId, x.Width }); + }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TrickplayInfos"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs new file mode 100644 index 000000000..2884d4256 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs @@ -0,0 +1,654 @@ +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20230923170422_UserCastReceiver")] + partial class UserCastReceiver + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => + { + b.HasOne("Jellyfin.Data.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs new file mode 100644 index 000000000..f06410c15 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class UserCastReceiver : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<string>( + name: "CastReceiverId", + table: "Users", + type: "TEXT", + maxLength: 32, + nullable: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CastReceiverId", + table: "Users"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index d23508096..f725ababe 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -1,4 +1,4 @@ -// <auto-generated /> +// <auto-generated /> using System; using Jellyfin.Server.Implementations; using Microsoft.EntityFrameworkCore; @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -442,6 +442,37 @@ namespace Jellyfin.Server.Implementations.Migrations b.ToTable("DeviceOptions"); }); + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => { b.Property<Guid>("Id") @@ -457,6 +488,10 @@ namespace Jellyfin.Server.Implementations.Migrations .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + b.Property<bool>("DisplayCollectionsView") .HasColumnType("INTEGER"); diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs new file mode 100644 index 000000000..dc1c17e5e --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/TrickplayInfoConfiguration.cs @@ -0,0 +1,18 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration +{ + /// <summary> + /// FluentAPI configuration for the TrickplayInfo entity. + /// </summary> + public class TrickplayInfoConfiguration : IEntityTypeConfiguration<TrickplayInfo> + { + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<TrickplayInfo> builder) + { + builder.HasKey(info => new { info.ItemId, info.Width }); + } + } +} diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs new file mode 100644 index 000000000..b960feb7f --- /dev/null +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -0,0 +1,474 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Implementations.Trickplay; + +/// <summary> +/// ITrickplayManager implementation. +/// </summary> +public class TrickplayManager : ITrickplayManager +{ + private readonly ILogger<TrickplayManager> _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly EncodingHelper _encodingHelper; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _config; + private readonly IImageEncoder _imageEncoder; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IApplicationPaths _appPaths; + + private static readonly SemaphoreSlim _resourcePool = new(1, 1); + private static readonly string[] _trickplayImgExtensions = { ".jpg" }; + + /// <summary> + /// Initializes a new instance of the <see cref="TrickplayManager"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="mediaEncoder">The media encoder.</param> + /// <param name="fileSystem">The file systen.</param> + /// <param name="encodingHelper">The encoding helper.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="config">The server configuration manager.</param> + /// <param name="imageEncoder">The image encoder.</param> + /// <param name="dbProvider">The database provider.</param> + /// <param name="appPaths">The application paths.</param> + public TrickplayManager( + ILogger<TrickplayManager> logger, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + EncodingHelper encodingHelper, + ILibraryManager libraryManager, + IServerConfigurationManager config, + IImageEncoder imageEncoder, + IDbContextFactory<JellyfinDbContext> dbProvider, + IApplicationPaths appPaths) + { + _logger = logger; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _encodingHelper = encodingHelper; + _libraryManager = libraryManager; + _config = config; + _imageEncoder = imageEncoder; + _dbProvider = dbProvider; + _appPaths = appPaths; + } + + /// <inheritdoc /> + public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken) + { + _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace); + + var options = _config.Configuration.TrickplayOptions; + foreach (var width in options.WidthResolutions) + { + cancellationToken.ThrowIfCancellationRequested(); + await RefreshTrickplayDataInternal( + video, + replace, + width, + options, + cancellationToken).ConfigureAwait(false); + } + } + + private async Task RefreshTrickplayDataInternal( + Video video, + bool replace, + int width, + TrickplayOptions options, + CancellationToken cancellationToken) + { + if (!CanGenerateTrickplay(video, options.Interval)) + { + return; + } + + var imgTempDir = string.Empty; + var outputDir = GetTrickplayDirectory(video, width); + + await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + 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) + { + _logger.LogDebug("Found no matching media source for item {ItemId}", video.Id); + return; + } + + 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(); + + // Create tiles + var trickplayInfo = CreateTiles(images, width, options, outputDir); + + // Save tiles info + try + { + if (trickplayInfo is not null) + { + trickplayInfo.ItemId = video.Id; + await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false); + + _logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath); + } + else + { + throw new InvalidOperationException("Null trickplay tiles info from CreateTiles."); + } + } + 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); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating trickplay images."); + } + finally + { + _resourcePool.Release(); + + if (!string.IsNullOrEmpty(imgTempDir)) + { + Directory.Delete(imgTempDir, true); + } + } + } + + /// <inheritdoc /> + public TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir) + { + if (images.Count == 0) + { + throw new ArgumentException("Can't create trickplay from 0 images."); + } + + var workDir = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + var trickplayInfo = new TrickplayInfo + { + Width = width, + Interval = options.Interval, + TileWidth = options.TileWidth, + TileHeight = options.TileHeight, + ThumbnailCount = images.Count, + // Set during image generation + Height = 0, + Bandwidth = 0 + }; + + /* + * Generate trickplay tiles from sets of thumbnails + */ + var imageOptions = new ImageCollageOptions + { + Width = trickplayInfo.TileWidth, + Height = trickplayInfo.TileHeight + }; + + var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight; + var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile); + + for (int i = 0; i < requiredTiles; i++) + { + // Set output/input paths + var tilePath = Path.Combine(workDir, $"{i}.jpg"); + + imageOptions.OutputPath = tilePath; + imageOptions.InputPaths = images.GetRange(i * thumbnailsPerTile, Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))); + + // Generate image and use returned height for tiles info + var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null); + if (trickplayInfo.Height == 0) + { + trickplayInfo.Height = height; + } + + // Update bitrate + var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tilePath).Length * 8 / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000)); + trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate); + } + + /* + * Move trickplay tiles to output directory + */ + Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName); + + // Replace existing tiles if they already exist + if (Directory.Exists(outputDir)) + { + Directory.Delete(outputDir, true); + } + + MoveDirectory(workDir, outputDir); + + return trickplayInfo; + } + + private bool CanGenerateTrickplay(Video video, int interval) + { + var videoType = video.VideoType; + if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay) + { + return false; + } + + if (video.IsPlaceHolder) + { + return false; + } + + if (video.IsShortcut) + { + return false; + } + + if (!video.IsCompleteMedia) + { + return false; + } + + if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks) + { + return false; + } + + var libraryOptions = _libraryManager.GetLibraryOptions(video); + if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction) + { + return false; + } + + // Can't extract images if there are no video streams + return video.GetMediaStreams().Count > 0; + } + + /// <inheritdoc /> + public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId) + { + var trickplayResolutions = new Dictionary<int, TrickplayInfo>(); + + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var trickplayInfos = await dbContext.TrickplayInfos + .AsNoTracking() + .Where(i => i.ItemId.Equals(itemId)) + .ToListAsync() + .ConfigureAwait(false); + + foreach (var info in trickplayInfos) + { + trickplayResolutions[info.Width] = info; + } + } + + return trickplayResolutions; + } + + /// <inheritdoc /> + public async Task SaveTrickplayInfo(TrickplayInfo info) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false); + if (oldInfo is not null) + { + dbContext.TrickplayInfos.Remove(oldInfo); + } + + dbContext.Add(info); + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + + /// <inheritdoc /> + public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item) + { + var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>(); + foreach (var mediaSource in item.GetMediaSources(false)) + { + var mediaSourceId = Guid.Parse(mediaSource.Id); + var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false); + + if (trickplayResolutions.Count > 0) + { + trickplayManifest[mediaSource.Id] = trickplayResolutions; + } + } + + return trickplayManifest; + } + + /// <inheritdoc /> + public string GetTrickplayTilePath(BaseItem item, int width, int index) + { + return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg"); + } + + /// <inheritdoc /> + public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey) + { + var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false); + if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo)) + { + var builder = new StringBuilder(128); + + if (trickplayInfo.ThumbnailCount > 0) + { + const string urlFormat = "Trickplay/{0}/{1}.jpg?MediaSourceId={2}&api_key={3}"; + const string decimalFormat = "{0:0.###}"; + + var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}"; + var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}"; + var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight; + var thumbnailDuration = trickplayInfo.Interval / 1000d; + var infDuration = thumbnailDuration * thumbnailsPerTile; + var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile); + + builder + .AppendLine("#EXTM3U") + .Append("#EXT-X-TARGETDURATION:") + .AppendLine(tileCount.ToString(CultureInfo.InvariantCulture)) + .AppendLine("#EXT-X-VERSION:7") + .AppendLine("#EXT-X-MEDIA-SEQUENCE:1") + .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") + .AppendLine("#EXT-X-IMAGES-ONLY"); + + for (int i = 0; i < tileCount; i++) + { + // All tiles prior to the last must contain full amount of thumbnails (no black). + if (i == tileCount - 1) + { + thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile); + infDuration = thumbnailDuration * thumbnailsPerTile; + } + + // EXTINF + builder + .Append("#EXTINF:") + .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration) + .AppendLine(","); + + // EXT-X-TILES + builder + .Append("#EXT-X-TILES:RESOLUTION=") + .Append(resolution) + .Append(",LAYOUT=") + .Append(layout) + .Append(",DURATION=") + .AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration) + .AppendLine(); + + // URL + builder + .AppendFormat( + CultureInfo.InvariantCulture, + urlFormat, + width.ToString(CultureInfo.InvariantCulture), + i.ToString(CultureInfo.InvariantCulture), + itemId.ToString("N"), + apiKey) + .AppendLine(); + } + + builder.AppendLine("#EXT-X-ENDLIST"); + return builder.ToString(); + } + } + + return null; + } + + private string GetTrickplayDirectory(BaseItem item, int? width = null) + { + var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay"); + + return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path; + } + + private void MoveDirectory(string source, string destination) + { + try + { + Directory.Move(source, destination); + } + catch (IOException) + { + // Cross device move requires a copy + Directory.CreateDirectory(destination); + foreach (string file in Directory.GetFiles(source)) + { + File.Copy(file, Path.Join(destination, Path.GetFileName(file)), true); + } + + Directory.Delete(source, true); + } + } +} diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index bfae81e4c..edc6aa173 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller; using Microsoft.EntityFrameworkCore; @@ -13,7 +14,7 @@ namespace Jellyfin.Server.Implementations.Users /// <summary> /// Manages the storage and retrieval of display preferences through Entity Framework. /// </summary> - public class DisplayPreferencesManager : IDisplayPreferencesManager + public sealed class DisplayPreferencesManager : IDisplayPreferencesManager, IAsyncDisposable { private readonly JellyfinDbContext _dbContext; @@ -97,5 +98,11 @@ namespace Jellyfin.Server.Implementations.Users { _dbContext.SaveChanges(); } + + /// <inheritdoc /> + public async ValueTask DisposeAsync() + { + await _dbContext.DisposeAsync().ConfigureAwait(false); + } } } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 59d655c82..edae4cfc5 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -15,6 +15,7 @@ using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; @@ -43,6 +44,7 @@ namespace Jellyfin.Server.Implementations.Users private readonly InvalidAuthProvider _invalidAuthProvider; private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; + private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IDictionary<Guid, User> _users; @@ -55,13 +57,15 @@ namespace Jellyfin.Server.Implementations.Users /// <param name="appHost">The application host.</param> /// <param name="imageProcessor">The image processor.</param> /// <param name="logger">The logger.</param> + /// <param name="serverConfigurationManager">The system config manager.</param> public UserManager( IDbContextFactory<JellyfinDbContext> dbProvider, IEventManager eventManager, INetworkManager networkManager, IApplicationHost appHost, IImageProcessor imageProcessor, - ILogger<UserManager> logger) + ILogger<UserManager> logger, + IServerConfigurationManager serverConfigurationManager) { _dbProvider = dbProvider; _eventManager = eventManager; @@ -69,6 +73,7 @@ namespace Jellyfin.Server.Implementations.Users _appHost = appHost; _imageProcessor = imageProcessor; _logger = logger; + _serverConfigurationManager = serverConfigurationManager; _passwordResetProviders = appHost.GetExports<IPasswordResetProvider>(); _authenticationProviders = appHost.GetExports<IAuthenticationProvider>(); @@ -103,7 +108,7 @@ namespace Jellyfin.Server.Implementations.Users // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) - [GeneratedRegex("^[\\w\\ \\-'._@]+$")] + [GeneratedRegex(@"^[\w\ \-'._@]+$")] private static partial Regex ValidUsernameRegex(); /// <inheritdoc/> @@ -288,6 +293,7 @@ namespace Jellyfin.Server.Implementations.Users public UserDto GetUserDto(User user, string? remoteEndPoint = null) { var hasPassword = GetAuthenticationProvider(user).HasPassword(user); + var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications; return new UserDto { Name = user.Username, @@ -315,7 +321,11 @@ namespace Jellyfin.Server.Implementations.Users OrderedViews = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews), GroupedFolders = user.GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders), MyMediaExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes), - LatestItemsExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes) + LatestItemsExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes), + CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId) + ? castReceiverApplications.FirstOrDefault()?.Id + : castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id + ?? castReceiverApplications.FirstOrDefault()?.Id }, Policy = new UserPolicy { @@ -605,6 +615,13 @@ namespace Jellyfin.Server.Implementations.Users user.RememberSubtitleSelections = config.RememberSubtitleSelections; user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + // Only set cast receiver id if it is passed in and it exists in the server config. + if (!string.IsNullOrEmpty(config.CastReceiverId) + && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) + { + user.CastReceiverId = config.CastReceiverId; + } + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 4c116745b..c12c90a68 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -11,6 +11,7 @@ using Jellyfin.Server.Implementations.Activity; using Jellyfin.Server.Implementations.Devices; using Jellyfin.Server.Implementations.Events; using Jellyfin.Server.Implementations.Security; +using Jellyfin.Server.Implementations.Trickplay; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Controller; using MediaBrowser.Controller.BaseItemManager; @@ -21,6 +22,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Activity; using MediaBrowser.Providers.Lyric; using Microsoft.Extensions.Configuration; @@ -78,6 +80,7 @@ namespace Jellyfin.Server serviceCollection.AddSingleton<IUserManager, UserManager>(); serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>(); serviceCollection.AddSingleton<IDeviceManager, DeviceManager>(); + serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>(); // TODO search the assemblies instead of adding them manually? serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>(); diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 2db0b77cd..757b56a49 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -42,7 +42,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.RemoveDownloadImagesInAdvance), typeof(Routines.MigrateAuthenticationDb), typeof(Routines.FixPlaylistOwner), - typeof(Routines.MigrateRatingLevels) + typeof(Routines.MigrateRatingLevels), + typeof(Routines.AddDefaultCastReceivers) }; /// <summary> diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs new file mode 100644 index 000000000..75a6a6176 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs @@ -0,0 +1,55 @@ +using System; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.System; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to add the default cast receivers to the system config. +/// </summary> +public class AddDefaultCastReceivers : IMigrationRoutine +{ + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AddDefaultCastReceivers"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public AddDefaultCastReceivers(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public Guid Id => new("34A1A1C4-5572-418E-A2F8-32CDFE2668E8"); + + /// <inheritdoc /> + public string Name => "AddDefaultCastReceivers"; + + /// <inheritdoc /> + public bool PerformOnNewInstall => true; + + /// <inheritdoc /> + public void Perform() + { + // Only add if receiver list is empty. + if (_serverConfigurationManager.Configuration.CastReceiverApplications.Length == 0) + { + _serverConfigurationManager.Configuration.CastReceiverApplications = new CastReceiverApplication[] + { + new() + { + Id = "F007D354", + Name = "Stable" + }, + new() + { + Id = "6F511C87", + Name = "Unstable" + } + }; + + _serverConfigurationManager.SaveConfiguration(); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index e1a43bb48..ac5047401 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.IO; using Emby.Server.Implementations.Data; using MediaBrowser.Controller; -using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Globalization; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs index b263c173e..6acab13fe 100644 --- a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs index ac20120d9..975218ad7 100644 --- a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs @@ -1,4 +1,3 @@ -using System.Threading; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; diff --git a/MediaBrowser.Controller/Drawing/IImageEncoder.cs b/MediaBrowser.Controller/Drawing/IImageEncoder.cs index e5c8ebfaf..c7bfbdb53 100644 --- a/MediaBrowser.Controller/Drawing/IImageEncoder.cs +++ b/MediaBrowser.Controller/Drawing/IImageEncoder.cs @@ -81,5 +81,15 @@ namespace MediaBrowser.Controller.Drawing /// <param name="posters">The list of poster paths.</param> /// <param name="backdrops">The list of backdrop paths.</param> void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops); + + /// <summary> + /// Creates a new trickplay tile image. + /// </summary> + /// <param name="options">The options to use when creating the image. Width and Height are a quantity of thumbnails in this case, not pixels.</param> + /// <param name="quality">The image encode quality.</param> + /// <param name="imgWidth">The width of a single trickplay thumbnail.</param> + /// <param name="imgHeight">Optional height of a single trickplay thumbnail, if it is known.</param> + /// <returns>Height of single decoded trickplay thumbnail.</returns> + int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight); } } diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs index 615d236c7..dcd22a3b4 100644 --- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs +++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs @@ -95,10 +95,7 @@ namespace MediaBrowser.Controller.Entities } var p = destProps.Find(x => x.Name == sourceProp.Name); - if (p is not null) - { - p.SetValue(dest, v); - } + p?.SetValue(dest, v); } } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 44fe65103..e707eedbf 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -598,7 +598,7 @@ namespace MediaBrowser.Controller.Entities for (var i = 0; i < childrenCount; i++) { - await actionBlock.SendAsync(i).ConfigureAwait(false); + await actionBlock.SendAsync(i, cancellationToken).ConfigureAwait(false); } actionBlock.Complete(); diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index c721fb778..05540d490 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Controller.LiveTv { public class LiveTvProgram : BaseItem, IHasLookupInfo<ItemLookupInfo>, IHasStartDate, IHasProgramAttributes { - private static string EmbyServiceName = "Emby"; + private const string EmbyServiceName = "Emby"; public LiveTvProgram() { diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index fba347bda..6621ae284 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -100,6 +100,13 @@ namespace MediaBrowser.Controller.MediaEncoding { "truehd", 6 }, }; + private static readonly string _defaultMjpegEncoder = "mjpeg"; + private static readonly Dictionary<string, string> _mjpegCodecMap = new(StringComparer.OrdinalIgnoreCase) + { + { "vaapi", _defaultMjpegEncoder + "_vaapi" }, + { "qsv", _defaultMjpegEncoder + "_qsv" } + }; + public static readonly string[] LosslessAudioCodecs = new string[] { "alac", @@ -167,6 +174,24 @@ namespace MediaBrowser.Controller.MediaEncoding return defaultEncoder; } + private string GetMjpegEncoder(EncodingJobInfo state, EncodingOptions encodingOptions) + { + if (state.VideoType == VideoType.VideoFile) + { + var hwType = encodingOptions.HardwareAccelerationType; + + if (!string.IsNullOrEmpty(hwType) + && encodingOptions.EnableHardwareEncoding + && _mjpegCodecMap.TryGetValue(hwType, out var preferredEncoder) + && _mediaEncoder.SupportsEncoder(preferredEncoder)) + { + return preferredEncoder; + } + } + + return _defaultMjpegEncoder; + } + private bool IsVaapiSupported(EncodingJobInfo state) { // vaapi will throw an error with this input @@ -300,6 +325,11 @@ namespace MediaBrowser.Controller.MediaEncoding return GetH264Encoder(state, encodingOptions); } + if (string.Equals(codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) + { + return GetMjpegEncoder(state, encodingOptions); + } + if (string.Equals(codec, "vp8", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase)) { @@ -745,12 +775,17 @@ namespace MediaBrowser.Controller.MediaEncoding private string GetVaapiDeviceArgs(string renderNodePath, string driver, string kernelDriver, string srcDeviceAlias, string alias) { alias ??= VaapiAlias; - renderNodePath = renderNodePath ?? "/dev/dri/renderD128"; - var driverOpts = string.IsNullOrEmpty(driver) - ? ":" + renderNodePath - : ":,driver=" + driver + (string.IsNullOrEmpty(kernelDriver) ? string.Empty : ",kernel_driver=" + kernelDriver); + + // 'renderNodePath' has higher priority than 'kernelDriver' + var driverOpts = string.IsNullOrEmpty(renderNodePath) + ? (string.IsNullOrEmpty(kernelDriver) ? string.Empty : ",kernel_driver=" + kernelDriver) + : renderNodePath; + + // 'driver' behaves similarly to env LIBVA_DRIVER_NAME + driverOpts += string.IsNullOrEmpty(driver) ? string.Empty : ",driver=" + driver; + var options = string.IsNullOrEmpty(srcDeviceAlias) - ? driverOpts + ? (string.IsNullOrEmpty(driverOpts) ? string.Empty : ":" + driverOpts) : "@" + srcDeviceAlias; return string.Format( @@ -872,14 +907,14 @@ namespace MediaBrowser.Controller.MediaEncoding if (_mediaEncoder.IsVaapiDeviceInteliHD) { - args.Append(GetVaapiDeviceArgs(null, "iHD", null, null, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(options.VaapiDevice, "iHD", null, null, VaapiAlias)); } else if (_mediaEncoder.IsVaapiDeviceInteli965) { // Only override i965 since it has lower priority than iHD in libva lookup. Environment.SetEnvironmentVariable("LIBVA_DRIVER_NAME", "i965"); Environment.SetEnvironmentVariable("LIBVA_DRIVER_NAME_JELLYFIN", "i965"); - args.Append(GetVaapiDeviceArgs(null, "i965", null, null, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(options.VaapiDevice, "i965", null, null, VaapiAlias)); } var filterDevArgs = string.Empty; @@ -1745,11 +1780,9 @@ namespace MediaBrowser.Controller.MediaEncoding // Values 0-3, 0 being highest quality but slower var profileScore = 0; - string crf; var qmin = "0"; var qmax = "50"; - - crf = "10"; + var crf = "10"; if (isVc1) { @@ -2947,7 +2980,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*a)\\,min({0}\\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\\,ih)\\,min({0}/a\\,{1}))/2)*2", + @"scale=trunc(min(max(iw\,ih*a)\,min({0}\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\,ih)\,min({0}/a\,{1}))/2)*2", maxWidthParam, maxHeightParam, scaleVal); @@ -2989,7 +3022,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*a)\\,{0})/{1})*{1}:trunc(ow/a/2)*2", + @"scale=trunc(min(max(iw\,ih*a)\,{0})/{1})*{1}:trunc(ow/a/2)*2", maxWidthParam, scaleVal); } @@ -3001,7 +3034,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale=trunc(oh*a/{1})*{1}:min(max(iw/a\\,ih)\\,{0})", + @"scale=trunc(oh*a/{1})*{1}:min(max(iw/a\,ih)\,{0})", maxHeightParam, scaleVal); } @@ -3021,19 +3054,19 @@ namespace MediaBrowser.Controller.MediaEncoding switch (threedFormat.Value) { case Video3DFormat.HalfSideBySide: - filter = "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to requestedWidth. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. break; case Video3DFormat.FullSideBySide: - filter = "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // fsbs crop width in half,set the display aspect,crop out any black bars we may have made the scale width to requestedWidth. break; case Video3DFormat.HalfTopAndBottom: - filter = "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // htab crop height in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to requestedWidth break; case Video3DFormat.FullTopAndBottom: - filter = "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // ftab crop height in half, set the display aspect,crop out any black bars we may have made the scale width to requestedWidth break; default: @@ -4919,6 +4952,15 @@ namespace MediaBrowser.Controller.MediaEncoding subFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); overlayFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); + var framerate = GetFramerateParam(state); + if (framerate.HasValue) + { + mainFilters.Insert(0, string.Format( + CultureInfo.InvariantCulture, + "fps={0}", + framerate.Value)); + } + var mainStr = string.Empty; if (mainFilters?.Count > 0) { diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index 4114dea4f..c2cef4978 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; @@ -138,6 +140,36 @@ namespace MediaBrowser.Controller.MediaEncoding Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken); /// <summary> + /// Extracts the video images on interval. + /// </summary> + /// <param name="inputFile">Input file.</param> + /// <param name="container">Video container type.</param> + /// <param name="mediaSource">Media source information.</param> + /// <param name="imageStream">Media stream information.</param> + /// <param name="maxWidth">The maximum width.</param> + /// <param name="interval">The interval.</param> + /// <param name="allowHwAccel">Allow for hardware acceleration.</param> + /// <param name="threads">The input/output thread count for ffmpeg.</param> + /// <param name="qualityScale">The qscale value for ffmpeg.</param> + /// <param name="priority">The process priority for the ffmpeg process.</param> + /// <param name="encodingHelper">EncodingHelper instance.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Directory where images where extracted. A given image made before another will always be named with a lower number.</returns> + Task<string> ExtractVideoImagesOnIntervalAccelerated( + string inputFile, + string container, + MediaSourceInfo mediaSource, + MediaStream imageStream, + int maxWidth, + TimeSpan interval, + bool allowHwAccel, + int? threads, + int? qualityScale, + ProcessPriorityClass? priority, + EncodingHelper encodingHelper, + CancellationToken cancellationToken); + + /// <summary> /// Gets the media info. /// </summary> /// <param name="request">The request.</param> diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index e0942e490..0a706c307 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -34,7 +34,7 @@ namespace MediaBrowser.Controller.Net /// <summary> /// The logger. /// </summary> - protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; + protected readonly ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) { diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 16943f6aa..eb5069b06 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; diff --git a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs new file mode 100644 index 000000000..0c41f3023 --- /dev/null +++ b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Trickplay; + +/// <summary> +/// Interface ITrickplayManager. +/// </summary> +public interface ITrickplayManager +{ + /// <summary> + /// Generates new trickplay images and metadata. + /// </summary> + /// <param name="video">The video.</param> + /// <param name="replace">Whether or not existing data should be replaced.</param> + /// <param name="cancellationToken">CancellationToken to use for operation.</param> + /// <returns>Task.</returns> + Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken); + + /// <summary> + /// Creates trickplay tiles out of individual thumbnails. + /// </summary> + /// <param name="images">Ordered file paths of the thumbnails to be used.</param> + /// <param name="width">The width of a single thumbnail.</param> + /// <param name="options">The trickplay options.</param> + /// <param name="outputDir">The output directory.</param> + /// <returns>The associated trickplay information.</returns> + /// <remarks> + /// The output directory will be DELETED and replaced if it already exists. + /// </remarks> + TrickplayInfo CreateTiles(List<string> images, int width, TrickplayOptions options, string outputDir); + + /// <summary> + /// Get available trickplay resolutions and corresponding info. + /// </summary> + /// <param name="itemId">The item.</param> + /// <returns>Map of width resolutions to trickplay tiles info.</returns> + Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId); + + /// <summary> + /// Saves trickplay info. + /// </summary> + /// <param name="info">The trickplay info.</param> + /// <returns>Task.</returns> + Task SaveTrickplayInfo(TrickplayInfo info); + + /// <summary> + /// Gets all trickplay infos for all media streams of an item. + /// </summary> + /// <param name="item">The item.</param> + /// <returns>A map of media source id to a map of tile width to trickplay info.</returns> + Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item); + + /// <summary> + /// Gets the path to a trickplay tile image. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="width">The width of a single thumbnail.</param> + /// <param name="index">The tile's index.</param> + /// <returns>The absolute path.</returns> + string GetTrickplayTilePath(BaseItem item, int width, int index); + + /// <summary> + /// Gets the trickplay HLS playlist. + /// </summary> + /// <param name="itemId">The item.</param> + /// <param name="width">The width of a single thumbnail.</param> + /// <param name="apiKey">Optional api key of the requesting user.</param> + /// <returns>The text content of the .m3u8 playlist.</returns> + Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey); +} diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs index 9e7a1d50a..1f94d9b23 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.MediaEncoding.BdInfo; /// </summary> public class BdInfoFileInfo : BDInfo.IO.IFileInfo { - private FileSystemMetadata _impl; + private readonly FileSystemMetadata _impl; /// <summary> /// Initializes a new instance of the <see cref="BdInfoFileInfo" /> class. diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index db119ce5c..f12ef7e63 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -499,8 +499,8 @@ namespace MediaBrowser.MediaEncoding.Encoder var required = codec == Codec.Encoder ? _requiredEncoders : _requiredDecoders; - var found = Regex - .Matches(output, @"^\s\S{6}\s(?<codec>[\w|-]+)\s+.+$", RegexOptions.Multiline) + var found = CodecRegex() + .Matches(output) .Select(x => x.Groups["codec"].Value) .Where(x => required.Contains(x)); @@ -527,8 +527,8 @@ namespace MediaBrowser.MediaEncoding.Encoder return Enumerable.Empty<string>(); } - var found = Regex - .Matches(output, @"^\s\S{3}\s(?<filter>[\w|-]+)\s+.+$", RegexOptions.Multiline) + var found = FilterRegex() + .Matches(output) .Select(x => x.Groups["filter"].Value) .Where(x => _requiredFilters.Contains(x)); @@ -582,5 +582,11 @@ namespace MediaBrowser.MediaEncoding.Encoder return reader.ReadToEnd(); } } + + [GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)] + private static partial Regex CodecRegex(); + + [GeneratedRegex("^\\s\\S{3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)] + private static partial Regex FilterRegex(); } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 99c49e4ae..4668b8bbb 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -21,6 +21,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.MediaEncoding.Probing; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; @@ -28,8 +29,10 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using static Nikse.SubtitleEdit.Core.Common.IfoParser; namespace MediaBrowser.MediaEncoding.Encoder { @@ -177,7 +180,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (_ffmpegPath is not null) { // Determine a probe path from the mpeg path - _ffprobePath = FfprobePathRegex().Replace(_ffmpegPath, @"ffprobe$1"); + _ffprobePath = FfprobePathRegex().Replace(_ffmpegPath, "ffprobe$1"); // Interrogate to understand what coders are supported var validator = new EncoderValidator(_logger, _ffmpegPath); @@ -422,7 +425,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (request.MediaSource.AnalyzeDurationMs > 0) { - analyzeDuration = "-analyzeduration " + (request.MediaSource.AnalyzeDurationMs * 1000).ToString(); + analyzeDuration = "-analyzeduration " + (request.MediaSource.AnalyzeDurationMs * 1000); } else if (!string.IsNullOrEmpty(ffmpegAnalyzeDuration)) { @@ -623,9 +626,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private string GetImageResolutionParameter() { - string imageResolutionParameter; - - imageResolutionParameter = _serverConfig.Configuration.ChapterImageResolution switch + var imageResolutionParameter = _serverConfig.Configuration.ChapterImageResolution switch { ImageResolution.P144 => "256x144", ImageResolution.P240 => "426x240", @@ -670,13 +671,13 @@ namespace MediaBrowser.MediaEncoding.Encoder var scaler = threedFormat switch { // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. - Video3DFormat.HalfSideBySide => "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.HalfSideBySide => @"crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", // fsbs crop width in half,set the display aspect,crop out any black bars we may have made - Video3DFormat.FullSideBySide => "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.FullSideBySide => @"crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", // htab crop height in half,scale to correct size, set the display aspect,crop out any black bars we may have made - Video3DFormat.HalfTopAndBottom => "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.HalfTopAndBottom => @"crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", // ftab crop height in half, set the display aspect,crop out any black bars we may have made - Video3DFormat.FullTopAndBottom => "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.FullTopAndBottom => @"crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", _ => "scale=trunc(iw*sar):ih" }; @@ -783,6 +784,191 @@ namespace MediaBrowser.MediaEncoding.Encoder } /// <inheritdoc /> + public Task<string> ExtractVideoImagesOnIntervalAccelerated( + string inputFile, + string container, + MediaSourceInfo mediaSource, + MediaStream imageStream, + int maxWidth, + TimeSpan interval, + bool allowHwAccel, + int? threads, + int? qualityScale, + ProcessPriorityClass? priority, + EncodingHelper encodingHelper, + CancellationToken cancellationToken) + { + var options = allowHwAccel ? _configurationManager.GetEncodingOptions() : new EncodingOptions(); + threads ??= _threads; + + // A new EncodingOptions instance must be used as to not disable HW acceleration for all of Jellyfin. + // Additionally, we must set a few fields without defaults to prevent null pointer exceptions. + if (!allowHwAccel) + { + options.EnableHardwareEncoding = false; + options.HardwareAccelerationType = string.Empty; + options.EnableTonemapping = false; + } + + var baseRequest = new BaseEncodingJobOptions { MaxWidth = maxWidth, MaxFramerate = (float)(1.0 / interval.TotalSeconds) }; + var jobState = new EncodingJobInfo(TranscodingJobType.Progressive) + { + IsVideoRequest = true, // must be true for InputVideoHwaccelArgs to return non-empty value + MediaSource = mediaSource, + VideoStream = imageStream, + BaseRequest = baseRequest, // GetVideoProcessingFilterParam errors if null + MediaPath = inputFile, + OutputVideoCodec = "mjpeg" + }; + var vidEncoder = options.AllowMjpegEncoding ? encodingHelper.GetVideoEncoder(jobState, options) : jobState.OutputVideoCodec; + + // Get input and filter arguments + var inputArg = encodingHelper.GetInputArgument(jobState, options, container).Trim(); + if (string.IsNullOrWhiteSpace(inputArg)) + { + throw new InvalidOperationException("EncodingHelper returned empty input arguments."); + } + + if (!allowHwAccel) + { + inputArg = "-threads " + threads + " " + inputArg; // HW accel args set a different input thread count, only set if disabled + } + + var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, jobState.OutputVideoCodec).Trim(); + if (string.IsNullOrWhiteSpace(filterParam)) + { + throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters."); + } + + return ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken); + } + + private async Task<string> ExtractVideoImagesOnIntervalInternal( + string inputArg, + string filterParam, + string vidEncoder, + int? outputThreads, + int? qualityScale, + ProcessPriorityClass? priority, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(inputArg)) + { + throw new InvalidOperationException("Empty or invalid input argument."); + } + + // Output arguments + var targetDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(targetDirectory); + var outputPath = Path.Combine(targetDirectory, "%08d.jpg"); + + // Final command arguments + var args = string.Format( + CultureInfo.InvariantCulture, + "-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}-f {5} \"{6}\"", + inputArg, + filterParam, + outputThreads.GetValueOrDefault(_threads), + vidEncoder, + qualityScale.HasValue ? "-qscale:v " + qualityScale.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty, + "image2", + outputPath); + + // Start ffmpeg process + var process = new Process + { + StartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + FileName = _ffmpegPath, + Arguments = args, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false, + }, + EnableRaisingEvents = true + }; + + var processDescription = string.Format(CultureInfo.InvariantCulture, "{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); + _logger.LogInformation("Trickplay generation: {ProcessDescription}", processDescription); + + using (var processWrapper = new ProcessWrapper(process, this)) + { + bool ranToCompletion = false; + + await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + StartProcess(processWrapper); + + // Set process priority + if (priority.HasValue) + { + try + { + processWrapper.Process.PriorityClass = priority.Value; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to set process priority to {Priority} for {Description}", priority.Value, processDescription); + } + } + + // Need to give ffmpeg enough time to make all the thumbnails, which could be a while, + // but we still need to detect if the process hangs. + // Making the assumption that as long as new jpegs are showing up, everything is good. + + bool isResponsive = true; + int lastCount = 0; + var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs; + timeoutMs = timeoutMs <= 0 ? DefaultHdrImageExtractionTimeout : timeoutMs; + + while (isResponsive) + { + try + { + await process.WaitForExitAsync(TimeSpan.FromMilliseconds(timeoutMs)).ConfigureAwait(false); + + ranToCompletion = true; + break; + } + catch (OperationCanceledException) + { + // We don't actually expect the process to be finished in one timeout span, just that one image has been generated. + } + + cancellationToken.ThrowIfCancellationRequested(); + + var jpegCount = _fileSystem.GetFilePaths(targetDirectory).Count(); + + isResponsive = jpegCount > lastCount; + lastCount = jpegCount; + } + + if (!ranToCompletion) + { + _logger.LogInformation("Stopping trickplay extraction due to process inactivity."); + StopProcess(processWrapper, 1000); + } + } + finally + { + _thumbnailResourcePool.Release(); + } + + var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1; + + if (exitCode == -1) + { + _logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription); + + throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "ffmpeg image extraction failed for {0}", processDescription)); + } + + return targetDirectory; + } + } + public string GetTimeParameter(long ticks) { var time = TimeSpan.FromTicks(ticks); @@ -852,7 +1038,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // https://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping // We need to double escape - return path.Replace('\\', '/').Replace(":", "\\:", StringComparison.Ordinal).Replace("'", "'\\\\\\''", StringComparison.Ordinal); + return path.Replace('\\', '/').Replace(":", "\\:", StringComparison.Ordinal).Replace("'", @"'\\\''", StringComparison.Ordinal); } /// <inheritdoc /> diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 441a3abd4..be1e8a172 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// <summary> /// Class responsible for normalizing FFprobe output. /// </summary> - public class ProbeResultNormalizer + public partial class ProbeResultNormalizer { // When extracting subtitles, the maximum length to consider (to avoid invalid filenames) private const int MaxSubtitleDescriptionExtractionLength = 100; @@ -31,8 +31,6 @@ namespace MediaBrowser.MediaEncoding.Probing private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - private static readonly Regex _performerPattern = new(@"(?<name>.*) \((?<instrument>.*)\)"); - private readonly ILogger _logger; private readonly ILocalizationManager _localization; @@ -1215,7 +1213,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(performer, false)) { - Match match = _performerPattern.Match(person); + Match match = PerformerRegex().Match(person); // If the performer doesn't have any instrument/role associated, it won't match. In that case, chances are it's simply a band name, so we skip it. if (match.Success) @@ -1654,5 +1652,8 @@ namespace MediaBrowser.MediaEncoding.Probing return TransportStreamTimestamp.Valid; } + + [GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")] + private static partial Regex PerformerRegex(); } } diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 3f0e98ec8..84c735f9c 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -50,6 +50,7 @@ public class EncodingOptions EnableHardwareEncoding = true; AllowHevcEncoding = false; AllowAv1Encoding = false; + AllowMjpegEncoding = false; EnableSubtitleExtraction = true; AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" }; HardwareDecodingCodecs = new string[] { "h264", "vc1" }; @@ -256,6 +257,11 @@ public class EncodingOptions public bool AllowAv1Encoding { get; set; } /// <summary> + /// Gets or sets a value indicating whether MJPEG encoding is enabled. + /// </summary> + public bool AllowMjpegEncoding { get; set; } + + /// <summary> /// Gets or sets a value indicating whether subtitle extraction is enabled. /// </summary> public bool EnableSubtitleExtraction { get; set; } diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 9743edb1c..fbad29143 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -35,6 +35,10 @@ namespace MediaBrowser.Model.Configuration public bool ExtractChapterImagesDuringLibraryScan { get; set; } + public bool EnableTrickplayImageExtraction { get; set; } + + public bool ExtractTrickplayImagesDuringLibraryScan { get; set; } + public MediaPathInfo[] PathInfos { get; set; } public bool SaveLocalMetadata { get; set; } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 78a310f0b..1c9cc6c01 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -4,265 +4,276 @@ using System; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.System; using MediaBrowser.Model.Updates; -namespace MediaBrowser.Model.Configuration +namespace MediaBrowser.Model.Configuration; + +/// <summary> +/// Represents the server configuration. +/// </summary> +public class ServerConfiguration : BaseApplicationConfiguration { /// <summary> - /// Represents the server configuration. + /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. /// </summary> - public class ServerConfiguration : BaseApplicationConfiguration + public ServerConfiguration() { - /// <summary> - /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. - /// </summary> - public ServerConfiguration() + MetadataOptions = new[] { - MetadataOptions = new[] + new MetadataOptions() + { + ItemType = "Book" + }, + new MetadataOptions() + { + ItemType = "Movie" + }, + new MetadataOptions + { + ItemType = "MusicVideo", + DisabledMetadataFetchers = new[] { "The Open Movie Database" }, + DisabledImageFetchers = new[] { "The Open Movie Database" } + }, + new MetadataOptions + { + ItemType = "Series", + }, + new MetadataOptions + { + ItemType = "MusicAlbum", + DisabledMetadataFetchers = new[] { "TheAudioDB" } + }, + new MetadataOptions + { + ItemType = "MusicArtist", + DisabledMetadataFetchers = new[] { "TheAudioDB" } + }, + new MetadataOptions + { + ItemType = "BoxSet" + }, + new MetadataOptions { - new MetadataOptions() - { - ItemType = "Book" - }, - new MetadataOptions() - { - ItemType = "Movie" - }, - new MetadataOptions - { - ItemType = "MusicVideo", - DisabledMetadataFetchers = new[] { "The Open Movie Database" }, - DisabledImageFetchers = new[] { "The Open Movie Database" } - }, - new MetadataOptions - { - ItemType = "Series", - }, - new MetadataOptions - { - ItemType = "MusicAlbum", - DisabledMetadataFetchers = new[] { "TheAudioDB" } - }, - new MetadataOptions - { - ItemType = "MusicArtist", - DisabledMetadataFetchers = new[] { "TheAudioDB" } - }, - new MetadataOptions - { - ItemType = "BoxSet" - }, - new MetadataOptions - { - ItemType = "Season", - }, - new MetadataOptions - { - ItemType = "Episode", - } - }; - } - - /// <summary> - /// Gets or sets a value indicating whether to enable prometheus metrics exporting. - /// </summary> - public bool EnableMetrics { get; set; } = false; - - public bool EnableNormalizedItemByNameIds { get; set; } = true; - - /// <summary> - /// Gets or sets a value indicating whether this instance is port authorized. - /// </summary> - /// <value><c>true</c> if this instance is port authorized; otherwise, <c>false</c>.</value> - public bool IsPortAuthorized { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether quick connect is available for use on this server. - /// </summary> - public bool QuickConnectAvailable { get; set; } = true; - - /// <summary> - /// Gets or sets a value indicating whether [enable case sensitive item ids]. - /// </summary> - /// <value><c>true</c> if [enable case sensitive item ids]; otherwise, <c>false</c>.</value> - public bool EnableCaseSensitiveItemIds { get; set; } = true; - - public bool DisableLiveTvChannelUserDataName { get; set; } = true; - - /// <summary> - /// Gets or sets the metadata path. - /// </summary> - /// <value>The metadata path.</value> - public string MetadataPath { get; set; } = string.Empty; - - public string MetadataNetworkPath { get; set; } = string.Empty; - - /// <summary> - /// Gets or sets the preferred metadata language. - /// </summary> - /// <value>The preferred metadata language.</value> - public string PreferredMetadataLanguage { get; set; } = "en"; - - /// <summary> - /// Gets or sets the metadata country code. - /// </summary> - /// <value>The metadata country code.</value> - public string MetadataCountryCode { get; set; } = "US"; - - /// <summary> - /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name. - /// </summary> - /// <value>The sort replace characters.</value> - public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" }; - - /// <summary> - /// Gets or sets characters to be removed from strings to create a sort name. - /// </summary> - /// <value>The sort remove characters.</value> - public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" }; - - /// <summary> - /// Gets or sets words to be removed from strings to create a sort name. - /// </summary> - /// <value>The sort remove words.</value> - public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" }; - - /// <summary> - /// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated. - /// </summary> - /// <value>The min resume PCT.</value> - public int MinResumePct { get; set; } = 5; - - /// <summary> - /// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. - /// </summary> - /// <value>The max resume PCT.</value> - public int MaxResumePct { get; set; } = 90; - - /// <summary> - /// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates.. - /// </summary> - /// <value>The min resume duration seconds.</value> - public int MinResumeDurationSeconds { get; set; } = 300; - - /// <summary> - /// Gets or sets the minimum minutes of a book that must be played in order for playstate to be updated. - /// </summary> - /// <value>The min resume in minutes.</value> - public int MinAudiobookResume { get; set; } = 5; - - /// <summary> - /// Gets or sets the remaining minutes of a book that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. - /// </summary> - /// <value>The remaining time in minutes.</value> - public int MaxAudiobookResume { get; set; } = 5; - - /// <summary> - /// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed - /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several - /// different directories and files. - /// </summary> - /// <value>The file watcher delay.</value> - public int LibraryMonitorDelay { get; set; } = 60; - - /// <summary> - /// Gets or sets the duration in seconds that we will wait after a library updated event before executing the library changed notification. - /// </summary> - /// <value>The library update duration.</value> - public int LibraryUpdateDuration { get; set; } = 30; - - /// <summary> - /// Gets or sets the image saving convention. - /// </summary> - /// <value>The image saving convention.</value> - public ImageSavingConvention ImageSavingConvention { get; set; } - - public MetadataOptions[] MetadataOptions { get; set; } - - public bool SkipDeserializationForBasicTypes { get; set; } = true; - - public string ServerName { get; set; } = string.Empty; - - public string UICulture { get; set; } = "en-US"; - - public bool SaveMetadataHidden { get; set; } = false; - - public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>(); - - public int RemoteClientBitrateLimit { get; set; } - - public bool EnableFolderView { get; set; } = false; - - public bool EnableGroupingIntoCollections { get; set; } = false; - - public bool DisplaySpecialsWithinSeasons { get; set; } = true; - - public string[] CodecsUsed { get; set; } = Array.Empty<string>(); - - public RepositoryInfo[] PluginRepositories { get; set; } = Array.Empty<RepositoryInfo>(); - - public bool EnableExternalContentInSuggestions { get; set; } = true; - - public int ImageExtractionTimeoutMs { get; set; } - - public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>(); - - /// <summary> - /// Gets or sets a value indicating whether slow server responses should be logged as a warning. - /// </summary> - public bool EnableSlowResponseWarning { get; set; } = true; - - /// <summary> - /// Gets or sets the threshold for the slow response time warning in ms. - /// </summary> - public long SlowResponseThresholdMs { get; set; } = 500; - - /// <summary> - /// Gets or sets the cors hosts. - /// </summary> - public string[] CorsHosts { get; set; } = new[] { "*" }; - - /// <summary> - /// Gets or sets the number of days we should retain activity logs. - /// </summary> - public int? ActivityLogRetentionDays { get; set; } = 30; + ItemType = "Season", + }, + new MetadataOptions + { + ItemType = "Episode", + } + }; + } - /// <summary> - /// Gets or sets the how the library scan fans out. - /// </summary> - public int LibraryScanFanoutConcurrency { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to enable prometheus metrics exporting. + /// </summary> + public bool EnableMetrics { get; set; } = false; - /// <summary> - /// Gets or sets the how many metadata refreshes can run concurrently. - /// </summary> - public int LibraryMetadataRefreshConcurrency { get; set; } + public bool EnableNormalizedItemByNameIds { get; set; } = true; - /// <summary> - /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. - /// </summary> - public bool RemoveOldPlugins { get; set; } + /// <summary> + /// Gets or sets a value indicating whether this instance is port authorized. + /// </summary> + /// <value><c>true</c> if this instance is port authorized; otherwise, <c>false</c>.</value> + public bool IsPortAuthorized { get; set; } - /// <summary> - /// Gets or sets a value indicating whether clients should be allowed to upload logs. - /// </summary> - public bool AllowClientLogUpload { get; set; } = true; + /// <summary> + /// Gets or sets a value indicating whether quick connect is available for use on this server. + /// </summary> + public bool QuickConnectAvailable { get; set; } = true; - /// <summary> - /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation alltogether. - /// </summary> - /// <value>The dummy chapters duration.</value> - public int DummyChapterDuration { get; set; } + /// <summary> + /// Gets or sets a value indicating whether [enable case sensitive item ids]. + /// </summary> + /// <value><c>true</c> if [enable case sensitive item ids]; otherwise, <c>false</c>.</value> + public bool EnableCaseSensitiveItemIds { get; set; } = true; - /// <summary> - /// Gets or sets the chapter image resolution. - /// </summary> - /// <value>The chapter image resolution.</value> - public ImageResolution ChapterImageResolution { get; set; } = ImageResolution.MatchSource; + public bool DisableLiveTvChannelUserDataName { get; set; } = true; - /// <summary> - /// Gets or sets the limit for parallel image encoding. - /// </summary> - /// <value>The limit for parallel image encoding.</value> - public int ParallelImageEncodingLimit { get; set; } - } + /// <summary> + /// Gets or sets the metadata path. + /// </summary> + /// <value>The metadata path.</value> + public string MetadataPath { get; set; } = string.Empty; + + public string MetadataNetworkPath { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the preferred metadata language. + /// </summary> + /// <value>The preferred metadata language.</value> + public string PreferredMetadataLanguage { get; set; } = "en"; + + /// <summary> + /// Gets or sets the metadata country code. + /// </summary> + /// <value>The metadata country code.</value> + public string MetadataCountryCode { get; set; } = "US"; + + /// <summary> + /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name. + /// </summary> + /// <value>The sort replace characters.</value> + public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" }; + + /// <summary> + /// Gets or sets characters to be removed from strings to create a sort name. + /// </summary> + /// <value>The sort remove characters.</value> + public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" }; + + /// <summary> + /// Gets or sets words to be removed from strings to create a sort name. + /// </summary> + /// <value>The sort remove words.</value> + public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" }; + + /// <summary> + /// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated. + /// </summary> + /// <value>The min resume PCT.</value> + public int MinResumePct { get; set; } = 5; + + /// <summary> + /// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. + /// </summary> + /// <value>The max resume PCT.</value> + public int MaxResumePct { get; set; } = 90; + + /// <summary> + /// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates.. + /// </summary> + /// <value>The min resume duration seconds.</value> + public int MinResumeDurationSeconds { get; set; } = 300; + + /// <summary> + /// Gets or sets the minimum minutes of a book that must be played in order for playstate to be updated. + /// </summary> + /// <value>The min resume in minutes.</value> + public int MinAudiobookResume { get; set; } = 5; + + /// <summary> + /// Gets or sets the remaining minutes of a book that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. + /// </summary> + /// <value>The remaining time in minutes.</value> + public int MaxAudiobookResume { get; set; } = 5; + + /// <summary> + /// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed + /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several + /// different directories and files. + /// </summary> + /// <value>The file watcher delay.</value> + public int LibraryMonitorDelay { get; set; } = 60; + + /// <summary> + /// Gets or sets the duration in seconds that we will wait after a library updated event before executing the library changed notification. + /// </summary> + /// <value>The library update duration.</value> + public int LibraryUpdateDuration { get; set; } = 30; + + /// <summary> + /// Gets or sets the image saving convention. + /// </summary> + /// <value>The image saving convention.</value> + public ImageSavingConvention ImageSavingConvention { get; set; } + + public MetadataOptions[] MetadataOptions { get; set; } + + public bool SkipDeserializationForBasicTypes { get; set; } = true; + + public string ServerName { get; set; } = string.Empty; + + public string UICulture { get; set; } = "en-US"; + + public bool SaveMetadataHidden { get; set; } = false; + + public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>(); + + public int RemoteClientBitrateLimit { get; set; } + + public bool EnableFolderView { get; set; } = false; + + public bool EnableGroupingIntoCollections { get; set; } = false; + + public bool DisplaySpecialsWithinSeasons { get; set; } = true; + + public string[] CodecsUsed { get; set; } = Array.Empty<string>(); + + public RepositoryInfo[] PluginRepositories { get; set; } = Array.Empty<RepositoryInfo>(); + + public bool EnableExternalContentInSuggestions { get; set; } = true; + + public int ImageExtractionTimeoutMs { get; set; } + + public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>(); + + /// <summary> + /// Gets or sets a value indicating whether slow server responses should be logged as a warning. + /// </summary> + public bool EnableSlowResponseWarning { get; set; } = true; + + /// <summary> + /// Gets or sets the threshold for the slow response time warning in ms. + /// </summary> + public long SlowResponseThresholdMs { get; set; } = 500; + + /// <summary> + /// Gets or sets the cors hosts. + /// </summary> + public string[] CorsHosts { get; set; } = new[] { "*" }; + + /// <summary> + /// Gets or sets the number of days we should retain activity logs. + /// </summary> + public int? ActivityLogRetentionDays { get; set; } = 30; + + /// <summary> + /// Gets or sets the how the library scan fans out. + /// </summary> + public int LibraryScanFanoutConcurrency { get; set; } + + /// <summary> + /// Gets or sets the how many metadata refreshes can run concurrently. + /// </summary> + public int LibraryMetadataRefreshConcurrency { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. + /// </summary> + public bool RemoveOldPlugins { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether clients should be allowed to upload logs. + /// </summary> + public bool AllowClientLogUpload { get; set; } = true; + + /// <summary> + /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation alltogether. + /// </summary> + /// <value>The dummy chapters duration.</value> + public int DummyChapterDuration { get; set; } + + /// <summary> + /// Gets or sets the chapter image resolution. + /// </summary> + /// <value>The chapter image resolution.</value> + public ImageResolution ChapterImageResolution { get; set; } = ImageResolution.MatchSource; + + /// <summary> + /// Gets or sets the limit for parallel image encoding. + /// </summary> + /// <value>The limit for parallel image encoding.</value> + public int ParallelImageEncodingLimit { get; set; } + + /// <summary> + /// Gets or sets the list of cast receiver applications. + /// </summary> + public CastReceiverApplication[] CastReceiverApplications { get; set; } = Array.Empty<CastReceiverApplication>(); + + /// <summary> + /// Gets or sets the trickplay options. + /// </summary> + /// <value>The trickplay options.</value> + public TrickplayOptions TrickplayOptions { get; set; } = new TrickplayOptions(); } diff --git a/MediaBrowser.Model/Configuration/TrickplayOptions.cs b/MediaBrowser.Model/Configuration/TrickplayOptions.cs new file mode 100644 index 000000000..92c16ee84 --- /dev/null +++ b/MediaBrowser.Model/Configuration/TrickplayOptions.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace MediaBrowser.Model.Configuration; + +/// <summary> +/// Class TrickplayOptions. +/// </summary> +public class TrickplayOptions +{ + /// <summary> + /// Gets or sets a value indicating whether or not to use HW acceleration. + /// </summary> + public bool EnableHwAcceleration { get; set; } = false; + + /// <summary> + /// Gets or sets the behavior used by trickplay provider on library scan/update. + /// </summary> + public TrickplayScanBehavior ScanBehavior { get; set; } = TrickplayScanBehavior.NonBlocking; + + /// <summary> + /// Gets or sets the process priority for the ffmpeg process. + /// </summary> + public ProcessPriorityClass ProcessPriority { get; set; } = ProcessPriorityClass.BelowNormal; + + /// <summary> + /// Gets or sets the interval, in ms, between each new trickplay image. + /// </summary> + public int Interval { get; set; } = 10000; + + /// <summary> + /// Gets or sets the target width resolutions, in px, to generates preview images for. + /// </summary> + public int[] WidthResolutions { get; set; } = new[] { 320 }; + + /// <summary> + /// Gets or sets number of tile images to allow in X dimension. + /// </summary> + public int TileWidth { get; set; } = 10; + + /// <summary> + /// Gets or sets number of tile images to allow in Y dimension. + /// </summary> + public int TileHeight { get; set; } = 10; + + /// <summary> + /// Gets or sets the ffmpeg output quality level. + /// </summary> + public int Qscale { get; set; } = 4; + + /// <summary> + /// Gets or sets the jpeg quality to use for image tiles. + /// </summary> + public int JpegQuality { get; set; } = 90; + + /// <summary> + /// Gets or sets the number of threads to be used by ffmpeg. + /// </summary> + public int ProcessThreads { get; set; } = 1; +} diff --git a/MediaBrowser.Model/Configuration/TrickplayScanBehavior.cs b/MediaBrowser.Model/Configuration/TrickplayScanBehavior.cs new file mode 100644 index 000000000..d0db53218 --- /dev/null +++ b/MediaBrowser.Model/Configuration/TrickplayScanBehavior.cs @@ -0,0 +1,17 @@ +namespace MediaBrowser.Model.Configuration; + +/// <summary> +/// Enum TrickplayScanBehavior. +/// </summary> +public enum TrickplayScanBehavior +{ + /// <summary> + /// Starts generation, only return once complete. + /// </summary> + Blocking, + + /// <summary> + /// Start generation, return immediately. + /// </summary> + NonBlocking +} diff --git a/MediaBrowser.Model/Configuration/UserConfiguration.cs b/MediaBrowser.Model/Configuration/UserConfiguration.cs index 94f354660..b477f2593 100644 --- a/MediaBrowser.Model/Configuration/UserConfiguration.cs +++ b/MediaBrowser.Model/Configuration/UserConfiguration.cs @@ -69,5 +69,10 @@ namespace MediaBrowser.Model.Configuration public bool RememberSubtitleSelections { get; set; } public bool EnableNextEpisodeAutoPlay { get; set; } + + /// <summary> + /// Gets or sets the id of the selected cast receiver. + /// </summary> + public string? CastReceiverId { get; set; } } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 889e2494a..666e78795 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -1313,7 +1313,7 @@ namespace MediaBrowser.Model.Dlna var audioFailureConditions = GetProfileConditionsForVideoAudio(profile.CodecProfiles, container, audioStream.Codec, audioStream.Channels, audioStream.BitRate, audioStream.SampleRate, audioStream.BitDepth, audioStream.Profile, mediaSource.IsSecondaryAudio(audioStream)); var audioStreamFailureReasons = AggregateFailureConditions(mediaSource, profile, "VideoAudioCodecProfile", audioFailureConditions); - if (audioStream?.IsExternal == true) + if (audioStream.IsExternal == true) { audioStreamFailureReasons |= TranscodeReason.AudioIsExternal; } diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs index 8fab1ca6d..287966dd0 100644 --- a/MediaBrowser.Model/Dto/BaseItemDto.cs +++ b/MediaBrowser.Model/Dto/BaseItemDto.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; @@ -569,6 +570,12 @@ namespace MediaBrowser.Model.Dto public List<ChapterInfo> Chapters { get; set; } /// <summary> + /// Gets or sets the trickplay manifest. + /// </summary> + /// <value>The trickplay manifest.</value> + public Dictionary<string, Dictionary<int, TrickplayInfo>> Trickplay { get; set; } + + /// <summary> /// Gets or sets the type of the location. /// </summary> /// <value>The type of the location.</value> diff --git a/MediaBrowser.Model/Querying/ItemFields.cs b/MediaBrowser.Model/Querying/ItemFields.cs index 6fa1d778a..242a1c6e9 100644 --- a/MediaBrowser.Model/Querying/ItemFields.cs +++ b/MediaBrowser.Model/Querying/ItemFields.cs @@ -34,6 +34,11 @@ namespace MediaBrowser.Model.Querying /// </summary> Chapters, + /// <summary> + /// The trickplay manifest. + /// </summary> + Trickplay, + ChildCount, /// <summary> diff --git a/MediaBrowser.Model/System/CastReceiverApplication.cs b/MediaBrowser.Model/System/CastReceiverApplication.cs new file mode 100644 index 000000000..6a49a5cac --- /dev/null +++ b/MediaBrowser.Model/System/CastReceiverApplication.cs @@ -0,0 +1,17 @@ +namespace MediaBrowser.Model.System; + +/// <summary> +/// The cast receiver application model. +/// </summary> +public class CastReceiverApplication +{ + /// <summary> + /// Gets or sets the cast receiver application id. + /// </summary> + public required string Id { get; set; } + + /// <summary> + /// Gets or sets the cast receiver application name. + /// </summary> + public required string Name { get; set; } +} diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index 502fc38ab..aa7c03ebd 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using MediaBrowser.Model.Updates; @@ -130,6 +131,11 @@ namespace MediaBrowser.Model.System public string TranscodingTempPath { get; set; } /// <summary> + /// Gets or sets the list of cast receiver applications. + /// </summary> + public IReadOnlyList<CastReceiverApplication> CastReceiverApplications { get; set; } + + /// <summary> /// Gets or sets a value indicating whether this instance has update available. /// </summary> /// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value> diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 75291b317..e336c8825 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -12,7 +12,6 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 6a40833d7..7ef70f4b0 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.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> diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 44f998742..d81704227 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -58,7 +58,7 @@ namespace MediaBrowser.Providers.MediaInfo _mediaSourceManager = mediaSourceManager; } - [GeneratedRegex("I:\\s+(.*?)\\s+LUFS")] + [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")] private static partial Regex LUFSRegex(); /// <summary> @@ -107,7 +107,6 @@ namespace MediaBrowser.Providers.MediaInfo if (libraryOptions.EnableLUFSScan) { - string output; using (var process = new Process() { StartInfo = new ProcessStartInfo @@ -131,7 +130,7 @@ namespace MediaBrowser.Providers.MediaInfo } using var reader = process.StandardError; - output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); MatchCollection split = LUFSRegex().Matches(output); diff --git a/MediaBrowser.Providers/Movies/ImdbExternalId.cs b/MediaBrowser.Providers/Movies/ImdbExternalId.cs index d00f37db5..a8d74aa0b 100644 --- a/MediaBrowser.Providers/Movies/ImdbExternalId.cs +++ b/MediaBrowser.Providers/Movies/ImdbExternalId.cs @@ -22,7 +22,7 @@ namespace MediaBrowser.Providers.Movies public ExternalIdMediaType? Type => null; /// <inheritdoc /> - public string? UrlFormatString => "https://www.imdb.com/title/{0}"; + public string UrlFormatString => "https://www.imdb.com/title/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs b/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs index 1bb5e1ea8..8151ab471 100644 --- a/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs +++ b/MediaBrowser.Providers/Movies/ImdbPersonExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Movies public ExternalIdMediaType? Type => ExternalIdMediaType.Person; /// <inheritdoc /> - public string? UrlFormatString => "https://www.imdb.com/name/{0}"; + public string UrlFormatString => "https://www.imdb.com/name/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Person; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs index 3a400575b..138cfef19 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => null; /// <inheritdoc /> - public string? UrlFormatString => "https://www.theaudiodb.com/album/{0}"; + public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs index b9e57eb26..8aceb48c0 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; /// <inheritdoc /> - public string? UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; + public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is MusicArtist; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs index f8f6253ff..014481da2 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => ExternalIdMediaType.Album; /// <inheritdoc /> - public string? UrlFormatString => "https://www.theaudiodb.com/album/{0}"; + public string UrlFormatString => "https://www.theaudiodb.com/album/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs index fd598c918..787539104 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherArtistExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; /// <inheritdoc /> - public string? UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; + public string UrlFormatString => "https://www.theaudiodb.com/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs index f7850781e..825fe32fa 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzAlbumArtistExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs index a9d4472e7..b7d53984c 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzAlbumExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.Album; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs index b89e67270..b3f001618 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzArtistExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is MusicArtist; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs index fdaa5574f..a0a922293 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzOtherArtistExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs index 0baab9955..47b6d6963 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzReleaseGroupExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs index 5c974c411..cb4345660 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -20,7 +20,7 @@ public class MusicBrainzTrackId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.Track; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs index 0e768bb83..d453a4ff4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs @@ -21,7 +21,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets public ExternalIdMediaType? Type => ExternalIdMediaType.BoxSet; /// <inheritdoc /> - public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}"; + public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "collection/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs index 38d2c5c69..6d6032e8f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs @@ -21,7 +21,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies public ExternalIdMediaType? Type => ExternalIdMediaType.Movie; /// <inheritdoc /> - public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}"; + public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs index 027399aec..d26a70028 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People public ExternalIdMediaType? Type => ExternalIdMediaType.Person; /// <inheritdoc /> - public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}"; + public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs index df04cb2e7..5f2d7909a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV public ExternalIdMediaType? Type => ExternalIdMediaType.Series; /// <inheritdoc /> - public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}"; + public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs index 087e4036a..3cb18e424 100644 --- a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs +++ b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs @@ -19,7 +19,7 @@ namespace MediaBrowser.Providers.TV public ExternalIdMediaType? Type => null; /// <inheritdoc /> - public string? UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}"; + public string UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Series; diff --git a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs new file mode 100644 index 000000000..69f10b43b --- /dev/null +++ b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using TagLib.Ape; + +namespace MediaBrowser.Providers.Trickplay; + +/// <summary> +/// Class TrickplayImagesTask. +/// </summary> +public class TrickplayImagesTask : IScheduledTask +{ + private const int QueryPageLimit = 100; + + private readonly ILogger<TrickplayImagesTask> _logger; + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; + private readonly ITrickplayManager _trickplayManager; + + /// <summary> + /// Initializes a new instance of the <see cref="TrickplayImagesTask"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="localization">The localization manager.</param> + /// <param name="trickplayManager">The trickplay manager.</param> + public TrickplayImagesTask( + ILogger<TrickplayImagesTask> logger, + ILibraryManager libraryManager, + ILocalizationManager localization, + ITrickplayManager trickplayManager) + { + _libraryManager = libraryManager; + _logger = logger; + _localization = localization; + _trickplayManager = trickplayManager; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskRefreshTrickplayImages"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskRefreshTrickplayImagesDescription"); + + /// <inheritdoc /> + public string Key => "RefreshTrickplayImages"; + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerDaily, + TimeOfDayTicks = TimeSpan.FromHours(3).Ticks + } + }; + } + + /// <inheritdoc /> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var query = new InternalItemsQuery + { + MediaTypes = new[] { MediaType.Video }, + SourceTypes = new[] { SourceType.Library }, + IsVirtualItem = false, + IsFolder = false, + Recursive = true, + Limit = QueryPageLimit + }; + + var numberOfVideos = _libraryManager.GetCount(query); + + var startIndex = 0; + var numComplete = 0; + + while (startIndex < numberOfVideos) + { + query.StartIndex = startIndex; + var videos = _libraryManager.GetItemList(query).OfType<Video>(); + + foreach (var video in videos) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await _trickplayManager.RefreshTrickplayDataAsync(video, false, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating trickplay files for {ItemName}", video.Name); + } + + numComplete++; + progress.Report(100d * numComplete / numberOfVideos); + } + + startIndex += QueryPageLimit; + } + + progress.Report(100); + } +} diff --git a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs new file mode 100644 index 000000000..f6dcde4f6 --- /dev/null +++ b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs @@ -0,0 +1,121 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Providers.Trickplay; + +/// <summary> +/// Class TrickplayProvider. Provides images and metadata for trickplay +/// scrubbing previews. +/// </summary> +public class TrickplayProvider : ICustomMetadataProvider<Episode>, + ICustomMetadataProvider<MusicVideo>, + ICustomMetadataProvider<Movie>, + ICustomMetadataProvider<Trailer>, + ICustomMetadataProvider<Video>, + IHasItemChangeMonitor, + IHasOrder, + IForcedProvider +{ + private readonly IServerConfigurationManager _config; + private readonly ITrickplayManager _trickplayManager; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="TrickplayProvider"/> class. + /// </summary> + /// <param name="config">The configuration manager.</param> + /// <param name="trickplayManager">The trickplay manager.</param> + /// <param name="libraryManager">The library manager.</param> + public TrickplayProvider( + IServerConfigurationManager config, + ITrickplayManager trickplayManager, + ILibraryManager libraryManager) + { + _config = config; + _trickplayManager = trickplayManager; + _libraryManager = libraryManager; + } + + /// <inheritdoc /> + public string Name => "Trickplay Provider"; + + /// <inheritdoc /> + public int Order => 100; + + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + if (item.IsFileProtocol) + { + var file = directoryService.GetFile(item.Path); + if (file is not null && item.DateModified != file.LastWriteTimeUtc) + { + return true; + } + } + + return false; + } + + /// <inheritdoc /> + public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + return FetchInternal(item, options, cancellationToken); + } + + /// <inheritdoc /> + public Task<ItemUpdateType> FetchAsync(MusicVideo item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + return FetchInternal(item, options, cancellationToken); + } + + /// <inheritdoc /> + public Task<ItemUpdateType> FetchAsync(Movie item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + return FetchInternal(item, options, cancellationToken); + } + + /// <inheritdoc /> + public Task<ItemUpdateType> FetchAsync(Trailer item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + return FetchInternal(item, options, cancellationToken); + } + + /// <inheritdoc /> + public Task<ItemUpdateType> FetchAsync(Video item, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + return FetchInternal(item, options, cancellationToken); + } + + private async Task<ItemUpdateType> FetchInternal(Video video, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var libraryOptions = _libraryManager.GetLibraryOptions(video); + bool? enableDuringScan = libraryOptions?.ExtractTrickplayImagesDuringLibraryScan; + bool replace = options.ReplaceAllImages; + + if (options.IsAutomated && !enableDuringScan.GetValueOrDefault(false)) + { + return ItemUpdateType.None; + } + + if (_config.Configuration.TrickplayOptions.ScanBehavior == TrickplayScanBehavior.Blocking) + { + await _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false); + } + else + { + _ = _trickplayManager.RefreshTrickplayDataAsync(video, replace, cancellationToken).ConfigureAwait(false); + } + + // The core doesn't need to trigger any save operations over this + return ItemUpdateType.None; + } +} diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 47a127950..70e5b66c1 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -160,7 +160,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers // Find last closing Tag // Need to do this in two steps to account for random > characters after the closing xml - var index = xml.LastIndexOf(@"</", StringComparison.Ordinal); + var index = xml.LastIndexOf("</", StringComparison.Ordinal); // If closing tag exists, move to end of Tag if (index != -1) diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs index af581fc5d..9b4e1731d 100644 --- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs @@ -13,7 +13,7 @@ namespace MediaBrowser.XbmcMetadata.Providers public abstract class BaseNfoProvider<T> : ILocalMetadataProvider<T>, IHasItemChangeMonitor where T : BaseItem, new() { - private IFileSystem _fileSystem; + private readonly IFileSystem _fileSystem; protected BaseNfoProvider(IFileSystem fileSystem) { diff --git a/RSSDP/HttpRequestParser.cs b/RSSDP/HttpRequestParser.cs index a1b4627a9..fab70eae2 100644 --- a/RSSDP/HttpRequestParser.cs +++ b/RSSDP/HttpRequestParser.cs @@ -33,10 +33,7 @@ namespace Rssdp.Infrastructure } finally { - if (retVal != null) - { - retVal.Dispose(); - } + retVal?.Dispose(); } } diff --git a/RSSDP/HttpResponseParser.cs b/RSSDP/HttpResponseParser.cs index 71b7a7b99..c570c84cb 100644 --- a/RSSDP/HttpResponseParser.cs +++ b/RSSDP/HttpResponseParser.cs @@ -33,10 +33,7 @@ namespace Rssdp.Infrastructure } catch { - if (retVal != null) - { - retVal.Dispose(); - } + retVal?.Dispose(); throw; } diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs index a3f30c174..42563e2ed 100644 --- a/RSSDP/SsdpCommunicationsServer.cs +++ b/RSSDP/SsdpCommunicationsServer.cs @@ -419,7 +419,7 @@ namespace Rssdp.Infrastructure { try { - var result = await socket.ReceiveMessageFromAsync(receiveBuffer, new IPEndPoint(IPAddress.Any, _LocalPort), CancellationToken.None).ConfigureAwait(false);; + var result = await socket.ReceiveMessageFromAsync(receiveBuffer, new IPEndPoint(IPAddress.Any, _LocalPort), CancellationToken.None).ConfigureAwait(false); if (result.ReceivedBytes > 0) { @@ -508,22 +508,16 @@ namespace Rssdp.Infrastructure } var handlers = RequestReceived; - if (handlers is not null) - { - handlers(this, new RequestReceivedEventArgs(data, remoteEndPoint, receivedOnlocalIPAddress)); - } + handlers?.Invoke(this, new RequestReceivedEventArgs(data, remoteEndPoint, receivedOnlocalIPAddress)); } private void OnResponseReceived(HttpResponseMessage data, IPEndPoint endPoint, IPAddress localIPAddress) { var handlers = ResponseReceived; - if (handlers is not null) + handlers?.Invoke(this, new ResponseReceivedEventArgs(data, endPoint) { - handlers(this, new ResponseReceivedEventArgs(data, endPoint) - { - LocalIPAddress = localIPAddress - }); - } + LocalIPAddress = localIPAddress + }); } } } diff --git a/RSSDP/SsdpDevice.cs b/RSSDP/SsdpDevice.cs index 3e4261b6a..569d733ea 100644 --- a/RSSDP/SsdpDevice.cs +++ b/RSSDP/SsdpDevice.cs @@ -337,10 +337,7 @@ namespace Rssdp protected virtual void OnDeviceAdded(SsdpEmbeddedDevice device) { var handlers = this.DeviceAdded; - if (handlers != null) - { - handlers(this, new DeviceEventArgs(device)); - } + handlers?.Invoke(this, new DeviceEventArgs(device)); } /// <summary> @@ -352,10 +349,7 @@ namespace Rssdp protected virtual void OnDeviceRemoved(SsdpEmbeddedDevice device) { var handlers = this.DeviceRemoved; - if (handlers != null) - { - handlers(this, new DeviceEventArgs(device)); - } + handlers?.Invoke(this, new DeviceEventArgs(device)); } } } diff --git a/RSSDP/SsdpDeviceLocator.cs b/RSSDP/SsdpDeviceLocator.cs index 82b09c4b4..d6fad4b9d 100644 --- a/RSSDP/SsdpDeviceLocator.cs +++ b/RSSDP/SsdpDeviceLocator.cs @@ -227,13 +227,10 @@ namespace Rssdp.Infrastructure } var handlers = DeviceAvailable; - if (handlers is not null) + handlers?.Invoke(this, new DeviceAvailableEventArgs(device, isNewDevice) { - handlers(this, new DeviceAvailableEventArgs(device, isNewDevice) - { - RemoteIPAddress = IPAddress - }); - } + RemoteIPAddress = IPAddress + }); } /// <summary> @@ -250,10 +247,7 @@ namespace Rssdp.Infrastructure } var handlers = DeviceUnavailable; - if (handlers is not null) - { - handlers(this, new DeviceUnavailableEventArgs(device, expired)); - } + handlers?.Invoke(this, new DeviceUnavailableEventArgs(device, expired)); } /// <summary> diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs index 65ae658a4..0ac9cc9a1 100644 --- a/RSSDP/SsdpDevicePublisher.cs +++ b/RSSDP/SsdpDevicePublisher.cs @@ -243,7 +243,7 @@ namespace Rssdp.Infrastructure } // Do not block synchronously as that may tie up a threadpool thread for several seconds. - Task.Delay(_Random.Next(16, maxWaitInterval * 1000)).ContinueWith((parentTask) => + Task.Delay(_Random.Next(16, maxWaitInterval * 1000), cancellationToken).ContinueWith((parentTask) => { // Copying devices to local array here to avoid threading issues/enumerator exceptions. IEnumerable<SsdpDevice> devices = null; @@ -281,7 +281,7 @@ namespace Rssdp.Infrastructure } } } - }); + }, cancellationToken); } private IEnumerable<SsdpDevice> GetAllDevicesAsFlatEnumerable() @@ -530,10 +530,7 @@ namespace Rssdp.Infrastructure { var timer = _RebroadcastAliveNotificationsTimer; _RebroadcastAliveNotificationsTimer = null; - if (timer is not null) - { - timer.Dispose(); - } + timer?.Dispose(); } private TimeSpan GetMinimumNonZeroCacheLifetime() @@ -567,10 +564,7 @@ namespace Rssdp.Infrastructure private void WriteTrace(string text) { - if (LogFunction is not null) - { - LogFunction(text); - } + LogFunction?.Invoke(text); // System.Diagnostics.Debug.WriteLine(text, "SSDP Publisher"); } diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 6c2086bee..e5cf638c1 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -13,7 +13,7 @@ RUN yum update -yq \ && yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git wget # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ff8c660f-ffa9-4814-ac2d-4089e6ec4eb5/dc806d344844f1d58d8015d105e85c65/dotnet-sdk-7.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 16002f790..777d92c11 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -12,7 +12,7 @@ RUN dnf update -yq \ && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget make # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ff8c660f-ffa9-4814-ac2d-4089e6ec4eb5/dc806d344844f1d58d8015d105e85c65/dotnet-sdk-7.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 2befb4b55..f34c0358d 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -17,7 +17,7 @@ RUN apt-get update -yqq \ libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ff8c660f-ffa9-4814-ac2d-4089e6ec4eb5/dc806d344844f1d58d8015d105e85c65/dotnet-sdk-7.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index 497a7a3dd..87466d20e 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ff8c660f-ffa9-4814-ac2d-4089e6ec4eb5/dc806d344844f1d58d8015d105e85c65/dotnet-sdk-7.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index fae7b209f..261deb3e4 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/ff8c660f-ffa9-4814-ac2d-4089e6ec4eb5/dc806d344844f1d58d8015d105e85c65/dotnet-sdk-7.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Program.cs b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs index 03b296494..1571b5ab0 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Program.cs +++ b/fuzz/Emby.Server.Implementations.Fuzz/Program.cs @@ -6,6 +6,7 @@ using Emby.Server.Implementations.Library; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Configuration; using Moq; using SharpFuzz; @@ -54,8 +55,16 @@ namespace Emby.Server.Implementations.Fuzz appHost.Setup(x => x.ReverseVirtualPath(It.IsAny<string>())) .Returns((string x) => x.Replace(MetaDataPath, VirtualMetaDataPath, StringComparison.Ordinal)); + var configSection = new Mock<IConfigurationSection>(); + configSection.SetupGet(x => x[It.Is<string>(s => s == MediaBrowser.Controller.Extensions.ConfigurationExtensions.SqliteCacheSizeKey)]) + .Returns("0"); + var config = new Mock<IConfiguration>(); + config.Setup(x => x.GetSection(It.Is<string>(s => s == MediaBrowser.Controller.Extensions.ConfigurationExtensions.SqliteCacheSizeKey))) + .Returns(configSection.Object); + IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); fixture.Inject(appHost); + fixture.Inject(config); return fixture.Create<SqliteItemRepository>(); } } diff --git a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh index 37e6bdb76..aa2a34cdd 100755 --- a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh +++ b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh @@ -8,4 +8,4 @@ cp bin/Emby.Server.Implementations.dll . dotnet build mkdir -p Findings -AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 -m 10240 dotnet bin/Debug/net6.0/Emby.Server.Implementations.Fuzz.dll "$1" +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net7.0/Emby.Server.Implementations.Fuzz "$1" diff --git a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj index 20bc4c724..da46e63a5 100644 --- a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj +++ b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj @@ -6,8 +6,8 @@ </PropertyGroup> <ItemGroup> - <Reference Include="Jellyfin.Server"> - <HintPath>jellyfin.dll</HintPath> + <Reference Include="Jellyfin.Api"> + <HintPath>Jellyfin.Api.dll</HintPath> </Reference> </ItemGroup> diff --git a/fuzz/Jellyfin.Server.Fuzz/Program.cs b/fuzz/Jellyfin.Api.Fuzz/Program.cs index e47286c13..6713322ac 100644 --- a/fuzz/Jellyfin.Server.Fuzz/Program.cs +++ b/fuzz/Jellyfin.Api.Fuzz/Program.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; -using Jellyfin.Server.Middleware; +using Jellyfin.Api.Middleware; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; using SharpFuzz; -namespace Emby.Server.Implementations.Fuzz +namespace Jellyfin.Api.Fuzz { public static class Program { diff --git a/fuzz/Jellyfin.Server.Fuzz/Testcases/UrlDecodeQueryFeature/test1.txt b/fuzz/Jellyfin.Api.Fuzz/Testcases/UrlDecodeQueryFeature/test1.txt index 73f356b93..73f356b93 100644 --- a/fuzz/Jellyfin.Server.Fuzz/Testcases/UrlDecodeQueryFeature/test1.txt +++ b/fuzz/Jellyfin.Api.Fuzz/Testcases/UrlDecodeQueryFeature/test1.txt diff --git a/fuzz/Jellyfin.Api.Fuzz/fuzz.sh b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh new file mode 100755 index 000000000..edf965562 --- /dev/null +++ b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -e + +dotnet build -c Release ../../Jellyfin.Api/Jellyfin.Api.csproj --output bin +sharpfuzz bin/Jellyfin.Api.dll +cp bin/Jellyfin.Api.dll . + +dotnet build +mkdir -p Findings +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net7.0/Jellyfin.Api.Fuzz "$1" diff --git a/fuzz/Jellyfin.Server.Fuzz/fuzz.sh b/fuzz/Jellyfin.Server.Fuzz/fuzz.sh deleted file mode 100755 index 303eb2135..000000000 --- a/fuzz/Jellyfin.Server.Fuzz/fuzz.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -set -e - -dotnet build -c Release ../../Jellyfin.Server/Jellyfin.Server.csproj --output bin -sharpfuzz bin/jellyfin.dll -cp bin/jellyfin.dll . - -dotnet build -mkdir -p Findings -AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 -m 10240 dotnet bin/Debug/net6.0/Jellyfin.Server.Fuzz.dll "$1" diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 000000000..25ba7d05c --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,20 @@ +# Jellyfin fuzzing + +## Setup + +Install AFL++ +```sh +git clone https://github.com/AFLplusplus/AFLplusplus +cd AFLplusplus +make all +sudo make install +``` + +Install SharpFuzz.CommandLine global .NET tool +```sh +dotnet tool install --global SharpFuzz.CommandLine +``` + +## Running +Run the `fuzz.sh` in the directory corresponding to the project you want to fuzz. +The script takes a parameter of which fuzz case you want to run. diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 03f90da8e..554fbe941 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -2,14 +2,18 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; +using System.Security.Cryptography.Xml; using BlurHashSharp.SkiaSharp; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Drawing; using Microsoft.Extensions.Logging; using SkiaSharp; +using static System.Net.Mime.MediaTypeNames; using SKSvg = SkiaSharp.Extended.Svg.SKSvg; namespace Jellyfin.Drawing.Skia; @@ -515,6 +519,81 @@ public class SkiaEncoder : IImageEncoder splashBuilder.GenerateSplash(posters, backdrops, outputPath); } + /// <inheritdoc /> + public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight) + { + var paths = options.InputPaths; + var tileWidth = options.Width; + var tileHeight = options.Height; + + if (paths.Count < 1) + { + throw new ArgumentException("InputPaths cannot be empty."); + } + else if (paths.Count > tileWidth * tileHeight) + { + throw new ArgumentException($"InputPaths contains more images than would fit on {tileWidth}x{tileHeight} grid."); + } + + // If no height provided, use height of first image. + if (!imgHeight.HasValue) + { + using var firstImg = Decode(paths[0], false, null, out _); + + if (firstImg is null) + { + throw new InvalidDataException("Could not decode image data."); + } + + if (firstImg.Width != imgWidth) + { + throw new InvalidOperationException("Image width does not match provided width."); + } + + imgHeight = firstImg.Height; + } + + // Make horizontal strips using every provided image. + using var tileGrid = new SKBitmap(imgWidth * tileWidth, imgHeight.Value * tileHeight); + using var canvas = new SKCanvas(tileGrid); + + var imgIndex = 0; + for (var y = 0; y < tileHeight; y++) + { + for (var x = 0; x < tileWidth; x++) + { + if (imgIndex >= paths.Count) + { + break; + } + + using var img = Decode(paths[imgIndex++], false, null, out _); + + if (img is null) + { + throw new InvalidDataException("Could not decode image data."); + } + + if (img.Width != imgWidth) + { + throw new InvalidOperationException("Image width does not match provided width."); + } + + if (img.Height != imgHeight) + { + throw new InvalidOperationException("Image height does not match first image height."); + } + + canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value); + } + } + + using var outputStream = new SKFileWStream(options.OutputPath); + tileGrid.Encode(outputStream, SKEncodedImageFormat.Jpeg, quality); + + return imgHeight.Value; + } + private void DrawIndicator(SKCanvas canvas, int imageWidth, int imageHeight, ImageProcessingOptions options) { try diff --git a/src/Jellyfin.Drawing/NullImageEncoder.cs b/src/Jellyfin.Drawing/NullImageEncoder.cs index 171128bed..1495661c1 100644 --- a/src/Jellyfin.Drawing/NullImageEncoder.cs +++ b/src/Jellyfin.Drawing/NullImageEncoder.cs @@ -50,6 +50,12 @@ public class NullImageEncoder : IImageEncoder } /// <inheritdoc /> + public int CreateTrickplayTile(ImageCollageOptions options, int quality, int imgWidth, int? imgHeight) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> public string GetImageBlurHash(int xComp, int yComp, string path) { throw new NotImplementedException(); diff --git a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs b/tests/Jellyfin.Api.Tests/Middleware/UrlDecodeQueryFeatureTests.cs index 93e065685..1ff7e7b7a 100644 --- a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs +++ b/tests/Jellyfin.Api.Tests/Middleware/UrlDecodeQueryFeatureTests.cs @@ -1,12 +1,11 @@ using System.Collections.Generic; using System.Linq; -using Jellyfin.Api.Middleware; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; using Xunit; -namespace Jellyfin.Server.Tests +namespace Jellyfin.Api.Middleware.Tests { public static class UrlDecodeQueryFeatureTests { diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs index c72a3315e..9b9c1ec34 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs @@ -14,18 +14,18 @@ namespace Jellyfin.Naming.Tests.AudioBook data.Add( new AudioBookFileInfo( - @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", + "/server/AudioBooks/Larry Potter/Larry Potter.mp3", "mp3")); data.Add( new AudioBookFileInfo( - @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", + "/server/AudioBooks/Berry Potter/Chapter 1 .ogg", "ogg", chapterNumber: 1)); data.Add( new AudioBookFileInfo( - @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", + "/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", "mp3", chapterNumber: 2, partNumber: 3)); @@ -49,7 +49,7 @@ namespace Jellyfin.Naming.Tests.AudioBook [Fact] public void Resolve_InvalidExtension() { - var result = new AudioBookResolver(_namingOptions).Resolve(@"/server/AudioBooks/Larry Potter/Larry Potter.mp9"); + var result = new AudioBookResolver(_namingOptions).Resolve("/server/AudioBooks/Larry Potter/Larry Potter.mp9"); Assert.Null(result); } diff --git a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs index 97949adff..ba602b5d2 100644 --- a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs @@ -20,11 +20,11 @@ public class ExternalPathParserTests var hindiCultureDto = new CultureDto("Hindi", "Hindi", "hi", new[] { "hin" }); var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("en.*", RegexOptions.IgnoreCase))) .Returns(englishCultureDto); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"fr.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("fr.*", RegexOptions.IgnoreCase))) .Returns(frenchCultureDto); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"hi.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("hi.*", RegexOptions.IgnoreCase))) .Returns(hindiCultureDto); _audioPathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Audio); diff --git a/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs b/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs index c9a295a4c..471616797 100644 --- a/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs +++ b/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs @@ -12,34 +12,34 @@ namespace Jellyfin.Naming.Tests.Music [InlineData("", false)] [InlineData("C:/", false)] [InlineData("/home/", false)] - [InlineData(@"blah blah", false)] - [InlineData(@"D:/music/weezer/03 Pinkerton", false)] - [InlineData(@"D:/music/michael jackson/Bad (2012 Remaster)", false)] - [InlineData(@"cd1", true)] - [InlineData(@"disc18", true)] - [InlineData(@"disk10", true)] - [InlineData(@"vol7", true)] - [InlineData(@"volume1", true)] - [InlineData(@"cd 1", true)] - [InlineData(@"disc 1", true)] - [InlineData(@"disk 1", true)] - [InlineData(@"disk", false)] - [InlineData(@"disk ·", false)] - [InlineData(@"disk a", false)] - [InlineData(@"disk volume", false)] - [InlineData(@"disc disc", false)] - [InlineData(@"disk disc 6", false)] - [InlineData(@"cd - 1", true)] - [InlineData(@"disc- 1", true)] - [InlineData(@"disk - 1", true)] - [InlineData(@"Disc 01 (Hugo Wolf · 24 Lieder)", true)] - [InlineData(@"Disc 04 (Encores and Folk Songs)", true)] - [InlineData(@"Disc04 (Encores and Folk Songs)", true)] - [InlineData(@"Disc 04(Encores and Folk Songs)", true)] - [InlineData(@"Disc04(Encores and Folk Songs)", true)] - [InlineData(@"D:/Video/MBTestLibrary/VideoTest/music/.38 special/anth/Disc 2", true)] - [InlineData(@"[1985] Opportunities (Let's make lots of money) (1985)", false)] - [InlineData(@"Blah 04(Encores and Folk Songs)", false)] + [InlineData("blah blah", false)] + [InlineData("D:/music/weezer/03 Pinkerton", false)] + [InlineData("D:/music/michael jackson/Bad (2012 Remaster)", false)] + [InlineData("cd1", true)] + [InlineData("disc18", true)] + [InlineData("disk10", true)] + [InlineData("vol7", true)] + [InlineData("volume1", true)] + [InlineData("cd 1", true)] + [InlineData("disc 1", true)] + [InlineData("disk 1", true)] + [InlineData("disk", false)] + [InlineData("disk ·", false)] + [InlineData("disk a", false)] + [InlineData("disk volume", false)] + [InlineData("disc disc", false)] + [InlineData("disk disc 6", false)] + [InlineData("cd - 1", true)] + [InlineData("disc- 1", true)] + [InlineData("disk - 1", true)] + [InlineData("Disc 01 (Hugo Wolf · 24 Lieder)", true)] + [InlineData("Disc 04 (Encores and Folk Songs)", true)] + [InlineData("Disc04 (Encores and Folk Songs)", true)] + [InlineData("Disc 04(Encores and Folk Songs)", true)] + [InlineData("Disc04(Encores and Folk Songs)", true)] + [InlineData("D:/Video/MBTestLibrary/VideoTest/music/.38 special/anth/Disc 2", true)] + [InlineData("[1985] Opportunities (Let's make lots of money) (1985)", false)] + [InlineData("Blah 04(Encores and Folk Songs)", false)] public void AlbumParser_MultidiscPath_Identifies(string path, bool result) { var parser = new AlbumParser(_namingOptions); diff --git a/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs index d0d3d8292..f2cd360e5 100644 --- a/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs @@ -9,11 +9,11 @@ namespace Jellyfin.Naming.Tests.TV private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); [Theory] - [InlineData(@"/server/anything_1996.11.14.mp4", "anything", 1996, 11, 14)] - [InlineData(@"/server/anything_1996-11-14.mp4", "anything", 1996, 11, 14)] - [InlineData(@"/server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv", "james.corden", 2017, 04, 20)] - [InlineData(@"/server/ABC News 2018_03_24_19_00_00.mkv", "ABC News", 2018, 03, 24)] - [InlineData(@"/server/Jeopardy 2023 07 14 HDTV x264 AC3.mkv", "Jeopardy", 2023, 07, 14)] + [InlineData("/server/anything_1996.11.14.mp4", "anything", 1996, 11, 14)] + [InlineData("/server/anything_1996-11-14.mp4", "anything", 1996, 11, 14)] + [InlineData("/server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv", "james.corden", 2017, 04, 20)] + [InlineData("/server/ABC News 2018_03_24_19_00_00.mkv", "ABC News", 2018, 03, 24)] + [InlineData("/server/Jeopardy 2023 07 14 HDTV x264 AC3.mkv", "Jeopardy", 2023, 07, 14)] // TODO: [InlineData(@"/server/anything_14.11.1996.mp4", "anything", 1996, 11, 14)] // TODO: [InlineData(@"/server/A Daily Show - (2015-01-15) - Episode Name - [720p].mkv", "A Daily Show", 2015, 01, 15)] // TODO: [InlineData(@"/server/Last Man Standing_KTLADT_2018_05_25_01_28_00.wtv", "Last Man Standing", 2018, 05, 25)] diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs index 1da5a30a8..1727b2247 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs @@ -9,16 +9,16 @@ namespace Jellyfin.Naming.Tests.TV private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); [Theory] - [InlineData(8, @"The Simpsons/The Simpsons.S25E08.Steal this episode.mp4")] - [InlineData(2, @"The Simpsons/The Simpsons - 02 - Ep Name.avi")] - [InlineData(2, @"The Simpsons/02.avi")] - [InlineData(2, @"The Simpsons/02 - Ep Name.avi")] - [InlineData(2, @"The Simpsons/02-Ep Name.avi")] - [InlineData(2, @"The Simpsons/02.EpName.avi")] - [InlineData(2, @"The Simpsons/The Simpsons - 02.avi")] - [InlineData(2, @"The Simpsons/The Simpsons - 02 Ep Name.avi")] - [InlineData(7, @"GJ Club (2013)/GJ Club - 07.mkv")] - [InlineData(17, @"Case Closed (1996-2007)/Case Closed - 317.mkv")] + [InlineData(8, "The Simpsons/The Simpsons.S25E08.Steal this episode.mp4")] + [InlineData(2, "The Simpsons/The Simpsons - 02 - Ep Name.avi")] + [InlineData(2, "The Simpsons/02.avi")] + [InlineData(2, "The Simpsons/02 - Ep Name.avi")] + [InlineData(2, "The Simpsons/02-Ep Name.avi")] + [InlineData(2, "The Simpsons/02.EpName.avi")] + [InlineData(2, "The Simpsons/The Simpsons - 02.avi")] + [InlineData(2, "The Simpsons/The Simpsons - 02 Ep Name.avi")] + [InlineData(7, "GJ Club (2013)/GJ Club - 07.mkv")] + [InlineData(17, "Case Closed (1996-2007)/Case Closed - 317.mkv")] // TODO: [InlineData(2, @"The Simpsons/The Simpsons 5 - 02 - Ep Name.avi")] // TODO: [InlineData(2, @"The Simpsons/The Simpsons 5 - 02 Ep Name.avi")] // TODO: [InlineData(7, @"Seinfeld/Seinfeld 0807 The Checks.avi")] diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs index 7604ddc80..5397f1371 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs @@ -13,10 +13,10 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/media/Foo - S04E011", true, "Foo", 4, 11)] [InlineData("/media/Foo/Foo s01x01", true, "Foo", 1, 1)] [InlineData("/media/Foo (2019)/Season 4/Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] - [InlineData("D:\\media\\Foo\\Foo-S01E01", true, "Foo", 1, 1)] - [InlineData("D:\\media\\Foo - S04E011", true, "Foo", 4, 11)] - [InlineData("D:\\media\\Foo\\Foo s01x01", true, "Foo", 1, 1)] - [InlineData("D:\\media\\Foo (2019)\\Season 4\\Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] + [InlineData(@"D:\media\Foo\Foo-S01E01", true, "Foo", 1, 1)] + [InlineData(@"D:\media\Foo - S04E011", true, "Foo", 4, 11)] + [InlineData(@"D:\media\Foo\Foo s01x01", true, "Foo", 1, 1)] + [InlineData(@"D:\media\Foo (2019)\Season 4\Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] [InlineData("/Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", false, "Elementary", 2, 3)] [InlineData("/Season 1/seriesname S01E02 blah.avi", false, "seriesname", 1, 2)] [InlineData("/Running Man/Running Man S2017E368.mkv", false, "Running Man", 2017, 368)] diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs index ffaa64c3f..6d6591abf 100644 --- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs @@ -9,66 +9,66 @@ namespace Jellyfin.Naming.Tests.TV private readonly EpisodePathParser _episodePathParser = new EpisodePathParser(new NamingOptions()); [Theory] - [InlineData(@"Season 1/4x01 – 20 Hours in America (1).mkv", null)] - [InlineData(@"Season 1/01x02 blah.avi", null)] - [InlineData(@"Season 1/S01x02 blah.avi", null)] - [InlineData(@"Season 1/S01E02 blah.avi", null)] - [InlineData(@"Season 1/S01xE02 blah.avi", null)] - [InlineData(@"Season 1/seriesname 01x02 blah.avi", null)] - [InlineData(@"Season 1/seriesname S01x02 blah.avi", null)] - [InlineData(@"Season 1/seriesname S01E02 blah.avi", null)] - [InlineData(@"Season 1/seriesname S01xE02 blah.avi", null)] - [InlineData(@"Season 2/02x03 - 04 Ep Name.mp4", null)] - [InlineData(@"Season 2/My show name 02x03 - 04 Ep Name.mp4", null)] - [InlineData(@"Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2/02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2/02x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/02x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/Elementary - 02x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/02x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/02x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", 26)] - [InlineData(@"Season 1/S01E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 1/4x01 – 20 Hours in America (1).mkv", null)] + [InlineData("Season 1/01x02 blah.avi", null)] + [InlineData("Season 1/S01x02 blah.avi", null)] + [InlineData("Season 1/S01E02 blah.avi", null)] + [InlineData("Season 1/S01xE02 blah.avi", null)] + [InlineData("Season 1/seriesname 01x02 blah.avi", null)] + [InlineData("Season 1/seriesname S01x02 blah.avi", null)] + [InlineData("Season 1/seriesname S01E02 blah.avi", null)] + [InlineData("Season 1/seriesname S01xE02 blah.avi", null)] + [InlineData("Season 2/02x03 - 04 Ep Name.mp4", null)] + [InlineData("Season 2/My show name 02x03 - 04 Ep Name.mp4", null)] + [InlineData("Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] + [InlineData("Season 2/02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] + [InlineData("Season 2/02x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 02/02x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 02/Elementary - 02x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 02/02x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 02/02x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 1/S01E23-E24-E26 - The Woman.mp4", 26)] // Four Digits seasons - [InlineData(@"Season 2009/2009x02 blah.avi", null)] - [InlineData(@"Season 2009/S2009x02 blah.avi", null)] - [InlineData(@"Season 2009/S2009E02 blah.avi", null)] - [InlineData(@"Season 2009/S2009xE02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname 2009x02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname S2009x02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname S2009E02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname S2009xE02 blah.avi", null)] - [InlineData(@"Season 2009/Elementary - 2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - S2009E23-E24-E26 - The Woman.mp4", 26)] - [InlineData(@"Season 2009/S2009E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 2009/2009x02 blah.avi", null)] + [InlineData("Season 2009/S2009x02 blah.avi", null)] + [InlineData("Season 2009/S2009E02 blah.avi", null)] + [InlineData("Season 2009/S2009xE02 blah.avi", null)] + [InlineData("Season 2009/seriesname 2009x02 blah.avi", null)] + [InlineData("Season 2009/seriesname S2009x02 blah.avi", null)] + [InlineData("Season 2009/seriesname S2009E02 blah.avi", null)] + [InlineData("Season 2009/seriesname S2009xE02 blah.avi", null)] + [InlineData("Season 2009/Elementary - 2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - S2009E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 2009/S2009E23-E24-E26 - The Woman.mp4", 26)] // Without season number - [InlineData(@"Season 1/02 - blah.avi", null)] - [InlineData(@"Season 2/02 - blah 14 blah.avi", null)] - [InlineData(@"Season 1/02 - blah-02 a.avi", null)] - [InlineData(@"Season 2/02.avi", null)] - [InlineData(@"Season 1/02-03 - blah.avi", 3)] - [InlineData(@"Season 2/02-04 - blah 14 blah.avi", 4)] - [InlineData(@"Season 1/02-05 - blah-02 a.avi", 5)] - [InlineData(@"Season 2/02-04.avi", 4)] - [InlineData(@"Season 2 /[HorribleSubs] Hunter X Hunter - 136[720p].mkv", null)] + [InlineData("Season 1/02 - blah.avi", null)] + [InlineData("Season 2/02 - blah 14 blah.avi", null)] + [InlineData("Season 1/02 - blah-02 a.avi", null)] + [InlineData("Season 2/02.avi", null)] + [InlineData("Season 1/02-03 - blah.avi", 3)] + [InlineData("Season 2/02-04 - blah 14 blah.avi", 4)] + [InlineData("Season 1/02-05 - blah-02 a.avi", 5)] + [InlineData("Season 2/02-04.avi", 4)] + [InlineData("Season 2 /[HorribleSubs] Hunter X Hunter - 136[720p].mkv", null)] // With format specification that must not be detected as ending episode number - [InlineData(@"Season 1/series-s09e14-1080p.mkv", null)] - [InlineData(@"Season 1/series-s09e14-720p.mkv", null)] - [InlineData(@"Season 1/series-s09e14-720i.mkv", null)] - [InlineData(@"Season 1/MOONLIGHTING_s01e01-e04.mkv", 4)] - [InlineData(@"Season 1/MOONLIGHTING_s01e01-e04", 4)] + [InlineData("Season 1/series-s09e14-1080p.mkv", null)] + [InlineData("Season 1/series-s09e14-720p.mkv", null)] + [InlineData("Season 1/series-s09e14-720i.mkv", null)] + [InlineData("Season 1/MOONLIGHTING_s01e01-e04.mkv", 4)] + [InlineData("Season 1/MOONLIGHTING_s01e01-e04", 4)] public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber) { var result = _episodePathParser.Parse(filename, false); diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs index 55af33836..6773bbeb1 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs @@ -6,23 +6,23 @@ namespace Jellyfin.Naming.Tests.TV public class SeasonFolderTests { [Theory] - [InlineData(@"/Drive/Season 1", 1, true)] - [InlineData(@"/Drive/Season 2", 2, true)] - [InlineData(@"/Drive/Season 02", 2, true)] - [InlineData(@"/Drive/Seinfeld/S02", 2, true)] - [InlineData(@"/Drive/Seinfeld/2", 2, true)] - [InlineData(@"/Drive/Season 2009", 2009, true)] - [InlineData(@"/Drive/Season1", 1, true)] - [InlineData(@"The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)] - [InlineData(@"/Drive/Season 7 (2016)", 7, false)] - [InlineData(@"/Drive/Staffel 7 (2016)", 7, false)] - [InlineData(@"/Drive/Stagione 7 (2016)", 7, false)] - [InlineData(@"/Drive/Season (8)", null, false)] - [InlineData(@"/Drive/3.Staffel", 3, false)] - [InlineData(@"/Drive/s06e05", null, false)] - [InlineData(@"/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)] - [InlineData(@"/Drive/extras", 0, true)] - [InlineData(@"/Drive/specials", 0, true)] + [InlineData("/Drive/Season 1", 1, true)] + [InlineData("/Drive/Season 2", 2, true)] + [InlineData("/Drive/Season 02", 2, true)] + [InlineData("/Drive/Seinfeld/S02", 2, true)] + [InlineData("/Drive/Seinfeld/2", 2, true)] + [InlineData("/Drive/Season 2009", 2009, true)] + [InlineData("/Drive/Season1", 1, true)] + [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)] + [InlineData("/Drive/Season 7 (2016)", 7, false)] + [InlineData("/Drive/Staffel 7 (2016)", 7, false)] + [InlineData("/Drive/Stagione 7 (2016)", 7, false)] + [InlineData("/Drive/Season (8)", null, false)] + [InlineData("/Drive/3.Staffel", 3, false)] + [InlineData("/Drive/s06e05", null, false)] + [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)] + [InlineData("/Drive/extras", 0, true)] + [InlineData("/Drive/specials", 0, true)] public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory) { var result = SeasonPathParser.Parse(path, true, true); diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs index 58ec1b5d2..94a953de3 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs @@ -51,8 +51,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 2009/Elementary - S2009E23-E24-E26 - The Woman.mp4", 2009)] [InlineData("Season 2009/S2009E23-E24-E26 - The Woman.mp4", 2009)] [InlineData("Series/1-12 - The Woman.mp4", 1)] - [InlineData(@"Running Man/Running Man S2017E368.mkv", 2017)] - [InlineData(@"Case Closed (1996-2007)/Case Closed - 317.mkv", 3)] + [InlineData("Running Man/Running Man S2017E368.mkv", 2017)] + [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 3)] // TODO: [InlineData(@"Seinfeld/Seinfeld 0807 The Checks.avi", 8)] public void GetSeasonNumberFromEpisodeFileTest(string path, int? expected) { diff --git a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs index fa46ecc3a..3721cd28c 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs @@ -21,8 +21,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Series/4x12 - The Woman.mp4", "", 4, 12)] [InlineData("Series/LA X, Pt. 1_s06e32.mp4", "LA X, Pt. 1", 6, 32)] [InlineData("[Baz-Bar]Foo - [1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] - [InlineData(@"/Foo/The.Series.Name.S01E04.WEBRip.x264-Baz[Bar]/the.series.name.s01e04.webrip.x264-Baz[Bar].mkv", "The.Series.Name", 1, 4)] - [InlineData(@"Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG/Love.Death.and.Robots.S01E01.Sonnies.Edge.1080p.NF.WEB-DL.DDP5.1.x264-NTG.mkv", "Love.Death.and.Robots", 1, 1)] + [InlineData("/Foo/The.Series.Name.S01E04.WEBRip.x264-Baz[Bar]/the.series.name.s01e04.webrip.x264-Baz[Bar].mkv", "The.Series.Name", 1, 4)] + [InlineData("Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG/Love.Death.and.Robots.S01E01.Sonnies.Edge.1080p.NF.WEB-DL.DDP5.1.x264-NTG.mkv", "Love.Death.and.Robots", 1, 1)] [InlineData("[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken/[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken - 12 (NVENC H.265 1080p).mkv", "Tensura Nikki - Tensei Shitara Slime Datta Ken", null, 12)] [InlineData("[Baz-Bar]Foo - 01 - 12[1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12, 12)] diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs index b1141df47..62d60e5a4 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs @@ -10,34 +10,34 @@ namespace Jellyfin.Naming.Tests.Video private readonly NamingOptions _namingOptions = new NamingOptions(); [Theory] - [InlineData(@"The Wolf of Wall Street (2013).mkv", "The Wolf of Wall Street", 2013)] - [InlineData(@"The Wolf of Wall Street 2 (2013).mkv", "The Wolf of Wall Street 2", 2013)] - [InlineData(@"The Wolf of Wall Street - 2 (2013).mkv", "The Wolf of Wall Street - 2", 2013)] - [InlineData(@"The Wolf of Wall Street 2001 (2013).mkv", "The Wolf of Wall Street 2001", 2013)] - [InlineData(@"300 (2006).mkv", "300", 2006)] - [InlineData(@"d:/movies/300 (2006).mkv", "300", 2006)] - [InlineData(@"300 2 (2006).mkv", "300 2", 2006)] - [InlineData(@"300 - 2 (2006).mkv", "300 - 2", 2006)] - [InlineData(@"300 2001 (2006).mkv", "300 2001", 2006)] - [InlineData(@"curse.of.chucky.2013.stv.unrated.multi.1080p.bluray.x264-rough", "curse.of.chucky", 2013)] - [InlineData(@"curse.of.chucky.2013.stv.unrated.multi.2160p.bluray.x264-rough", "curse.of.chucky", 2013)] - [InlineData(@"/server/Movies/300 (2007)/300 (2006).bluray.disc", "300", 2006)] - [InlineData(@"Arrival.2016.2160p.Blu-Ray.HEVC.mkv", "Arrival", 2016)] - [InlineData(@"The Wolf of Wall Street (2013)", "The Wolf of Wall Street", 2013)] - [InlineData(@"The Wolf of Wall Street 2 (2013)", "The Wolf of Wall Street 2", 2013)] - [InlineData(@"The Wolf of Wall Street - 2 (2013)", "The Wolf of Wall Street - 2", 2013)] - [InlineData(@"The Wolf of Wall Street 2001 (2013)", "The Wolf of Wall Street 2001", 2013)] - [InlineData(@"300 (2006)", "300", 2006)] - [InlineData(@"d:/movies/300 (2006)", "300", 2006)] - [InlineData(@"300 2 (2006)", "300 2", 2006)] - [InlineData(@"300 - 2 (2006)", "300 - 2", 2006)] - [InlineData(@"300 2001 (2006)", "300 2001", 2006)] - [InlineData(@"/server/Movies/300 (2007)/300 (2006)", "300", 2006)] - [InlineData(@"/server/Movies/300 (2007)/300 (2006).mkv", "300", 2006)] - [InlineData(@"American.Psycho.mkv", "American.Psycho.mkv", null)] - [InlineData(@"American Psycho.mkv", "American Psycho.mkv", null)] - [InlineData(@"[rec].mkv", "[rec].mkv", null)] - [InlineData(@"St. Vincent (2014)", "St. Vincent", 2014)] + [InlineData("The Wolf of Wall Street (2013).mkv", "The Wolf of Wall Street", 2013)] + [InlineData("The Wolf of Wall Street 2 (2013).mkv", "The Wolf of Wall Street 2", 2013)] + [InlineData("The Wolf of Wall Street - 2 (2013).mkv", "The Wolf of Wall Street - 2", 2013)] + [InlineData("The Wolf of Wall Street 2001 (2013).mkv", "The Wolf of Wall Street 2001", 2013)] + [InlineData("300 (2006).mkv", "300", 2006)] + [InlineData("d:/movies/300 (2006).mkv", "300", 2006)] + [InlineData("300 2 (2006).mkv", "300 2", 2006)] + [InlineData("300 - 2 (2006).mkv", "300 - 2", 2006)] + [InlineData("300 2001 (2006).mkv", "300 2001", 2006)] + [InlineData("curse.of.chucky.2013.stv.unrated.multi.1080p.bluray.x264-rough", "curse.of.chucky", 2013)] + [InlineData("curse.of.chucky.2013.stv.unrated.multi.2160p.bluray.x264-rough", "curse.of.chucky", 2013)] + [InlineData("/server/Movies/300 (2007)/300 (2006).bluray.disc", "300", 2006)] + [InlineData("Arrival.2016.2160p.Blu-Ray.HEVC.mkv", "Arrival", 2016)] + [InlineData("The Wolf of Wall Street (2013)", "The Wolf of Wall Street", 2013)] + [InlineData("The Wolf of Wall Street 2 (2013)", "The Wolf of Wall Street 2", 2013)] + [InlineData("The Wolf of Wall Street - 2 (2013)", "The Wolf of Wall Street - 2", 2013)] + [InlineData("The Wolf of Wall Street 2001 (2013)", "The Wolf of Wall Street 2001", 2013)] + [InlineData("300 (2006)", "300", 2006)] + [InlineData("d:/movies/300 (2006)", "300", 2006)] + [InlineData("300 2 (2006)", "300 2", 2006)] + [InlineData("300 - 2 (2006)", "300 - 2", 2006)] + [InlineData("300 2001 (2006)", "300 2001", 2006)] + [InlineData("/server/Movies/300 (2007)/300 (2006)", "300", 2006)] + [InlineData("/server/Movies/300 (2007)/300 (2006).mkv", "300", 2006)] + [InlineData("American.Psycho.mkv", "American.Psycho.mkv", null)] + [InlineData("American Psycho.mkv", "American Psycho.mkv", null)] + [InlineData("[rec].mkv", "[rec].mkv", null)] + [InlineData("St. Vincent (2014)", "St. Vincent", 2014)] [InlineData("Super movie(2009).mp4", "Super movie", 2009)] [InlineData("Drug War 2013.mp4", "Drug War", 2013)] [InlineData("My Movie (1997) - GreatestReleaseGroup 2019.mp4", "My Movie", 1997)] @@ -45,9 +45,9 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("First Man (2018) 1080p.mkv", "First Man", 2018)] [InlineData("Maximum Ride - 2016 - WEBDL-1080p - x264 AC3.mkv", "Maximum Ride", 2016)] // FIXME: [InlineData("Robin Hood [Multi-Subs] [2018].mkv", "Robin Hood", 2018)] - [InlineData(@"3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again + [InlineData("3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again [InlineData("3 days to kill (2005).mkv", "3 days to kill", 2005)] - [InlineData(@"Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)] + [InlineData("Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)] [InlineData("My Movie 2013.12.09", "My Movie 2013.12.09", null)] [InlineData("My Movie 2013-12-09", "My Movie 2013-12-09", null)] [InlineData("My Movie 20131209", "My Movie 20131209", null)] diff --git a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs index 511a014a6..fccce5bff 100644 --- a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs @@ -22,7 +22,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void Test3DName() { - var result = VideoResolver.ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions); + var result = VideoResolver.ResolveFile("C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions); Assert.Equal("hsbs", result?.Format3D); Assert.Equal("Oblivion", result?.Name); diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 294f11ee7..183ec8984 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -15,10 +15,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - 1080p.mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - [hsbs].mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - 1080p.mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - [hsbs].mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" }; var result = VideoListResolver.Resolve( @@ -34,10 +34,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - apple.mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - banana.mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - apple.mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - banana.mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" }; var result = VideoListResolver.Resolve( @@ -54,8 +54,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1925 version.mkv", - @"/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" + "/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1925 version.mkv", + "/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" }; var result = VideoListResolver.Resolve( @@ -71,13 +71,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/M/Movie 1.mkv", - @"/movies/M/Movie 2.mkv", - @"/movies/M/Movie 3.mkv", - @"/movies/M/Movie 4.mkv", - @"/movies/M/Movie 5.mkv", - @"/movies/M/Movie 6.mkv", - @"/movies/M/Movie 7.mkv" + "/movies/M/Movie 1.mkv", + "/movies/M/Movie 2.mkv", + "/movies/M/Movie 3.mkv", + "/movies/M/Movie 4.mkv", + "/movies/M/Movie 5.mkv", + "/movies/M/Movie 6.mkv", + "/movies/M/Movie 7.mkv" }; var result = VideoListResolver.Resolve( @@ -93,14 +93,14 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Movie/Movie.mkv", - @"/movies/Movie/Movie-2.mkv", - @"/movies/Movie/Movie-3.mkv", - @"/movies/Movie/Movie-4.mkv", - @"/movies/Movie/Movie-5.mkv", - @"/movies/Movie/Movie-6.mkv", - @"/movies/Movie/Movie-7.mkv", - @"/movies/Movie/Movie-8.mkv" + "/movies/Movie/Movie.mkv", + "/movies/Movie/Movie-2.mkv", + "/movies/Movie/Movie-3.mkv", + "/movies/Movie/Movie-4.mkv", + "/movies/Movie/Movie-5.mkv", + "/movies/Movie/Movie-6.mkv", + "/movies/Movie/Movie-7.mkv", + "/movies/Movie/Movie-8.mkv" }; var result = VideoListResolver.Resolve( @@ -116,15 +116,15 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Mo/Movie 1.mkv", - @"/movies/Mo/Movie 2.mkv", - @"/movies/Mo/Movie 3.mkv", - @"/movies/Mo/Movie 4.mkv", - @"/movies/Mo/Movie 5.mkv", - @"/movies/Mo/Movie 6.mkv", - @"/movies/Mo/Movie 7.mkv", - @"/movies/Mo/Movie 8.mkv", - @"/movies/Mo/Movie 9.mkv" + "/movies/Mo/Movie 1.mkv", + "/movies/Mo/Movie 2.mkv", + "/movies/Mo/Movie 3.mkv", + "/movies/Mo/Movie 4.mkv", + "/movies/Mo/Movie 5.mkv", + "/movies/Mo/Movie 6.mkv", + "/movies/Mo/Movie 7.mkv", + "/movies/Mo/Movie 8.mkv", + "/movies/Mo/Movie 9.mkv" }; var result = VideoListResolver.Resolve( @@ -140,11 +140,11 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Movie/Movie 1.mkv", - @"/movies/Movie/Movie 2.mkv", - @"/movies/Movie/Movie 3.mkv", - @"/movies/Movie/Movie 4.mkv", - @"/movies/Movie/Movie 5.mkv" + "/movies/Movie/Movie 1.mkv", + "/movies/Movie/Movie 2.mkv", + "/movies/Movie/Movie 3.mkv", + "/movies/Movie/Movie 4.mkv", + "/movies/Movie/Movie 5.mkv" }; var result = VideoListResolver.Resolve( @@ -162,11 +162,11 @@ namespace Jellyfin.Naming.Tests.Video var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man (2008).mkv", - @"/movies/Iron Man/Iron Man (2009).mkv", - @"/movies/Iron Man/Iron Man (2010).mkv", - @"/movies/Iron Man/Iron Man (2011).mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man (2008).mkv", + "/movies/Iron Man/Iron Man (2009).mkv", + "/movies/Iron Man/Iron Man (2010).mkv", + "/movies/Iron Man/Iron Man (2011).mkv" }; var result = VideoListResolver.Resolve( @@ -182,13 +182,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man-720p.mkv", - @"/movies/Iron Man/Iron Man-test.mkv", - @"/movies/Iron Man/Iron Man-bluray.mkv", - @"/movies/Iron Man/Iron Man-3d.mkv", - @"/movies/Iron Man/Iron Man-3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man[test].mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man-720p.mkv", + "/movies/Iron Man/Iron Man-test.mkv", + "/movies/Iron Man/Iron Man-bluray.mkv", + "/movies/Iron Man/Iron Man-3d.mkv", + "/movies/Iron Man/Iron Man-3d-hsbs.mkv", + "/movies/Iron Man/Iron Man[test].mkv" }; var result = VideoListResolver.Resolve( @@ -211,13 +211,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man - 720p.mkv", - @"/movies/Iron Man/Iron Man - test.mkv", - @"/movies/Iron Man/Iron Man - bluray.mkv", - @"/movies/Iron Man/Iron Man - 3d.mkv", - @"/movies/Iron Man/Iron Man - 3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man [test].mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man - 720p.mkv", + "/movies/Iron Man/Iron Man - test.mkv", + "/movies/Iron Man/Iron Man - bluray.mkv", + "/movies/Iron Man/Iron Man - 3d.mkv", + "/movies/Iron Man/Iron Man - 3d-hsbs.mkv", + "/movies/Iron Man/Iron Man [test].mkv" }; var result = VideoListResolver.Resolve( @@ -240,8 +240,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man - B (2006).mkv", - @"/movies/Iron Man/Iron Man - C (2007).mkv" + "/movies/Iron Man/Iron Man - B (2006).mkv", + "/movies/Iron Man/Iron Man - C (2007).mkv" }; var result = VideoListResolver.Resolve( @@ -256,13 +256,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man_720p.mkv", - @"/movies/Iron Man/Iron Man_test.mkv", - @"/movies/Iron Man/Iron Man_bluray.mkv", - @"/movies/Iron Man/Iron Man_3d.mkv", - @"/movies/Iron Man/Iron Man_3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man_3d.hsbs.mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man_720p.mkv", + "/movies/Iron Man/Iron Man_test.mkv", + "/movies/Iron Man/Iron Man_bluray.mkv", + "/movies/Iron Man/Iron Man_3d.mkv", + "/movies/Iron Man/Iron Man_3d-hsbs.mkv", + "/movies/Iron Man/Iron Man_3d.hsbs.mkv" }; var result = VideoListResolver.Resolve( @@ -280,11 +280,11 @@ namespace Jellyfin.Naming.Tests.Video var files = new[] { - @"/movies/Iron Man/Iron Man (2007).mkv", - @"/movies/Iron Man/Iron Man (2008).mkv", - @"/movies/Iron Man/Iron Man (2009).mkv", - @"/movies/Iron Man/Iron Man (2010).mkv", - @"/movies/Iron Man/Iron Man (2011).mkv" + "/movies/Iron Man/Iron Man (2007).mkv", + "/movies/Iron Man/Iron Man (2008).mkv", + "/movies/Iron Man/Iron Man (2009).mkv", + "/movies/Iron Man/Iron Man (2010).mkv", + "/movies/Iron Man/Iron Man (2011).mkv" }; var result = VideoListResolver.Resolve( @@ -300,8 +300,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Blade Runner (1982)/Blade Runner (1982) [Final Cut] [1080p HEVC AAC].mkv", - @"/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" + "/movies/Blade Runner (1982)/Blade Runner (1982) [Final Cut] [1080p HEVC AAC].mkv", + "/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" }; var result = VideoListResolver.Resolve( @@ -317,8 +317,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [1080p] Blu-ray.x264.DTS.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [1080p] Blu-ray.x264.DTS.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" }; var result = VideoListResolver.Resolve( @@ -334,12 +334,12 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", }; var result = VideoListResolver.Resolve( @@ -361,8 +361,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 1.mkv", - @"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" + "/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 1.mkv", + "/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" }; var result = VideoListResolver.Resolve( @@ -378,8 +378,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 1].mkv", - @"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" + "/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 1].mkv", + "/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" }; var result = VideoListResolver.Resolve( diff --git a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs index 97b52f749..c95703f53 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs @@ -384,8 +384,8 @@ namespace Jellyfin.Naming.Tests.Video // No stacking here because there is no part/disc/etc var files = new[] { - @"M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 01)", - @"M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 02)" + "M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 01)", + "M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 02)" }; var result = StackResolver.ResolveDirectories(files, _namingOptions).ToList(); diff --git a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs index 1d50df7a6..fc852ae85 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs @@ -29,7 +29,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestStubName() { - var result = VideoResolver.ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc", _namingOptions); + var result = VideoResolver.ResolveFile("C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc", _namingOptions); Assert.Equal("Oblivion", result?.Name); } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index 0316377d4..377f82eac 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -200,8 +200,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 1", - @"M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" + "M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 1", + "M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" }; var result = VideoListResolver.Resolve( @@ -217,8 +217,8 @@ namespace Jellyfin.Naming.Tests.Video // These should be considered separate, unrelated videos var files = new[] { - @"My movie #1.mp4", - @"My movie #2.mp4" + "My movie #1.mp4", + "My movie #2.mp4" }; var result = VideoListResolver.Resolve( @@ -233,10 +233,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"No (2012) part1.mp4", - @"No (2012) part2.mp4", - @"No (2012) part1-trailer.mp4", - @"No (2012)-trailer.mp4" + "No (2012) part1.mp4", + "No (2012) part2.mp4", + "No (2012) part1-trailer.mp4", + "No (2012)-trailer.mp4" }; var result = VideoListResolver.Resolve( @@ -254,10 +254,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Movies/Top Gun (1984)/movie.mp4", - @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer.mp4", - @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer2.mp4", - @"/Movies/trailer.mp4" + "/Movies/Top Gun (1984)/movie.mp4", + "/Movies/Top Gun (1984)/Top Gun (1984)-trailer.mp4", + "/Movies/Top Gun (1984)/Top Gun (1984)-trailer2.mp4", + "/Movies/trailer.mp4" }; var result = VideoListResolver.Resolve( @@ -276,10 +276,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd1.avi", - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd2.avi", - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd1.avi", - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi" + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd1.avi", + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd2.avi", + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd1.avi", + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi" }; var result = VideoListResolver.Resolve( @@ -294,7 +294,7 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" + "/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" }; var result = VideoListResolver.Resolve( @@ -309,7 +309,7 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"The Colony.mkv" + "The Colony.mkv" }; var result = VideoListResolver.Resolve( @@ -324,8 +324,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"Four Sisters and a Wedding - A.avi", - @"Four Sisters and a Wedding - B.avi" + "Four Sisters and a Wedding - A.avi", + "Four Sisters and a Wedding - B.avi" }; var result = VideoListResolver.Resolve( @@ -342,8 +342,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"Four Rooms - A.avi", - @"Four Rooms - A.mp4" + "Four Rooms - A.avi", + "Four Rooms - A.mp4" }; var result = VideoListResolver.Resolve( @@ -358,8 +358,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Server/Despicable Me/Despicable Me (2010).mkv", - @"/Server/Despicable Me/trailer.mkv" + "/Server/Despicable Me/Despicable Me (2010).mkv", + "/Server/Despicable Me/trailer.mkv" }; var result = VideoListResolver.Resolve( @@ -376,8 +376,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Server/Despicable Me/Despicable Me (2010).mkv", - @"/Server/Despicable Me/trailers/some title.mkv" + "/Server/Despicable Me/Despicable Me (2010).mkv", + "/Server/Despicable Me/trailers/some title.mkv" }; var result = VideoListResolver.Resolve( @@ -394,8 +394,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Movies/Despicable Me/Despicable Me.mkv", - @"/Movies/Despicable Me/trailers/trailer.mkv" + "/Movies/Despicable Me/Despicable Me.mkv", + "/Movies/Despicable Me/trailers/trailer.mkv" }; var result = VideoListResolver.Resolve( diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs index 33a99e107..8455a56a1 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs @@ -15,26 +15,26 @@ namespace Jellyfin.Naming.Tests.Video var data = new TheoryData<VideoFileInfo>(); data.Add( new VideoFileInfo( - path: @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv", + path: "/server/Movies/7 Psychos.mkv/7 Psychos.mkv", container: "mkv", name: "7 Psychos")); data.Add( new VideoFileInfo( - path: @"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", + path: "/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", container: "mkv", name: "3 days to kill", year: 2005)); data.Add( new VideoFileInfo( - path: @"/server/Movies/American Psycho/American.Psycho.mkv", + path: "/server/Movies/American Psycho/American.Psycho.mkv", container: "mkv", name: "American.Psycho")); data.Add( new VideoFileInfo( - path: @"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", + path: "/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", container: "mkv", name: "brave", year: 2006, @@ -43,14 +43,14 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", + path: "/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", container: "mkv", name: "300", year: 2006)); data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", + path: "/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", container: "mkv", name: "300", year: 2006, @@ -59,7 +59,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", + path: "/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", container: "disc", name: "brave", year: 2006, @@ -68,7 +68,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", + path: "/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", container: "disc", name: "300", year: 2006, @@ -77,7 +77,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/Brave (2007)/Brave (2006).bluray.disc", + path: "/server/Movies/Brave (2007)/Brave (2006).bluray.disc", container: "disc", name: "Brave", year: 2006, @@ -86,7 +86,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).bluray.disc", + path: "/server/Movies/300 (2007)/300 (2006).bluray.disc", container: "disc", name: "300", year: 2006, @@ -95,7 +95,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv", + path: "/server/Movies/300 (2007)/300 (2006)-trailer.mkv", container: "mkv", name: "300", year: 2006, @@ -103,7 +103,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", + path: "/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", container: "mkv", name: "Brave", year: 2006, @@ -111,28 +111,28 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).mkv", + path: "/server/Movies/300 (2007)/300 (2006).mkv", container: "mkv", name: "300", year: 2006)); data.Add( new VideoFileInfo( - path: @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", + path: "/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", container: "mkv", name: "Bad Boys", year: 1995)); data.Add( new VideoFileInfo( - path: @"/server/Movies/Brave (2007)/Brave (2006).mkv", + path: "/server/Movies/Brave (2007)/Brave (2006).mkv", container: "mkv", name: "Brave", year: 2006)); data.Add( new VideoFileInfo( - path: @"/server/Movies/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - JEFF/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - JEFF.mp4", + path: "/server/Movies/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - JEFF/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - JEFF.mp4", container: "mp4", name: "Rain Man", year: 1988)); @@ -174,8 +174,8 @@ namespace Jellyfin.Naming.Tests.Video { var paths = new[] { - @"/Server/Iron Man", - @"Batman", + "/Server/Iron Man", + "Batman", string.Empty }; diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index f157f01e5..d5ab6ab4b 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -352,11 +352,11 @@ namespace Jellyfin.Providers.Tests.Manager { if (forceRefresh) { - Assert.Matches(@"image url [0-9]", image.Path); + Assert.Matches("image url [0-9]", image.Path); } else { - Assert.DoesNotMatch(@"image url [0-9]", image.Path); + Assert.DoesNotMatch("image url [0-9]", image.Path); } } } diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 6ee4b8ef2..2b3867512 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -29,7 +29,7 @@ public class MediaInfoResolverTests public const string VideoDirectoryPath = "Test Data/Video"; public const string VideoDirectoryRegex = @"Test Data[/\\]Video"; public const string MetadataDirectoryPath = "library/00/00000000000000000000000000000000"; - public const string MetadataDirectoryRegex = @"library.*"; + public const string MetadataDirectoryRegex = "library.*"; private readonly ILocalizationManager _localizationManager; private readonly MediaInfoResolver _subtitleResolver; @@ -49,7 +49,7 @@ public class MediaInfoResolverTests var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" }); var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("en.*", RegexOptions.IgnoreCase))) .Returns(englishCultureDto); _localizationManager = localizationManager.Object; @@ -79,7 +79,7 @@ public class MediaInfoResolverTests { // need a media source manager capable of returning something other than file protocol var mediaSourceManager = new Mock<IMediaSourceManager>(); - mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex("http.*"))) .Returns(MediaProtocol.Http); BaseItem.MediaSourceManager = mediaSourceManager.Object; @@ -186,7 +186,7 @@ public class MediaInfoResolverTests { // need a media source manager capable of returning something other than file protocol var mediaSourceManager = new Mock<IMediaSourceManager>(); - mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex("http.*"))) .Returns(MediaProtocol.Http); BaseItem.MediaSourceManager = mediaSourceManager.Object; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index c33a957e6..1c35eb3f5 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -48,10 +48,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("C:/Users/jeff/myfile.mkv", "C:/Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] [InlineData("C:/Users/jeff/myfile.mkv", "C:/Users/jeff/", "/home/jeff", "/home/jeff/myfile.mkv")] [InlineData("/home/jeff/music/jeff's band/consistently inconsistent.mp3", "/home/jeff/music/jeff's band", "/home/not jeff", "/home/not jeff/consistently inconsistent.mp3")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff", "/home/jeff/", "/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff/", "/home/jeff/", "/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff/", "/", "/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff", "/home/jeff/", "/home/jeff/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff/", "/home/jeff/", "/home/jeff/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff/", "/", "/myfile.mkv")] [InlineData("/o", "/o", "/s", "/s")] // regression test for #5977 public void TryReplaceSubPath_ValidArgs_Correct(string path, string subPath, string newSubPath, string? expectedResult) { @@ -78,10 +78,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library [Theory] [InlineData(null, '/', null)] [InlineData(null, '\\', null)] - [InlineData("/home/jeff/myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] - [InlineData("C:\\Users\\Jeff\\myfile.mkv", '/', "C:/Users/Jeff/myfile.mkv")] - [InlineData("\\home/jeff\\myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] - [InlineData("\\home/jeff\\myfile.mkv", '/', "/home/jeff/myfile.mkv")] + [InlineData("/home/jeff/myfile.mkv", '\\', @"\home\jeff\myfile.mkv")] + [InlineData(@"C:\Users\Jeff\myfile.mkv", '/', "C:/Users/Jeff/myfile.mkv")] + [InlineData(@"\home/jeff\myfile.mkv", '\\', @"\home\jeff\myfile.mkv")] + [InlineData(@"\home/jeff\myfile.mkv", '/', "/home/jeff/myfile.mkv")] [InlineData("", '/', "")] public void NormalizePath_SpecifyingSeparator_Normalizes(string path, char separator, string expectedPath) { @@ -90,8 +90,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library [Theory] [InlineData("/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\Jeff\\myfile.mkv")] - [InlineData("\\home/jeff\\myfile.mkv")] + [InlineData(@"C:\Users\Jeff\myfile.mkv")] + [InlineData(@"\home/jeff\myfile.mkv")] public void NormalizePath_NoArgs_UsesDirectorySeparatorChar(string path) { var separator = Path.DirectorySeparatorChar; @@ -101,8 +101,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library [Theory] [InlineData("/home/jeff/myfile.mkv", '/')] - [InlineData("C:\\Users\\Jeff\\myfile.mkv", '\\')] - [InlineData("\\home/jeff\\myfile.mkv", '/')] + [InlineData(@"C:\Users\Jeff\myfile.mkv", '\\')] + [InlineData(@"\home/jeff\myfile.mkv", '/')] public void NormalizePath_OutVar_Correct(string path, char expectedSeparator) { var result = path.NormalizePath(out var separator); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index d4b90dac0..934024826 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -119,8 +119,8 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins [InlineData("C:\\some.dll")] // Windows root path. [InlineData("test.txt")] // Not a DLL [InlineData(".././.././../some.dll")] // Traversal with current and parent - [InlineData("..\\.\\..\\.\\..\\some.dll")] // Windows traversal with current and parent - [InlineData("\\\\network\\resource.dll")] // UNC Path + [InlineData(@"..\.\..\.\..\some.dll")] // Windows traversal with current and parent + [InlineData(@"\\network\resource.dll")] // UNC Path [InlineData("https://jellyfin.org/some.dll")] // URL [InlineData("~/some.dll")] // Tilde poses a shell expansion risk, but is a valid path character. public void Constructor_DiscoversUnsafePluginAssembly_Status_Malfunctioned(string unsafePath) @@ -191,13 +191,13 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins }; var metafilePath = Path.Combine(_pluginPath, "meta.json"); - File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + await File.WriteAllTextAsync(metafilePath, JsonSerializer.Serialize(partial, _options)); var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); @@ -231,7 +231,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); var metafilePath = Path.Combine(_pluginPath, "meta.json"); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); @@ -251,13 +251,13 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins }; var metafilePath = Path.Combine(_pluginPath, "meta.json"); - File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + await File.WriteAllTextAsync(metafilePath, JsonSerializer.Serialize(partial, _options)); var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); @@ -277,13 +277,13 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins }; var metafilePath = Path.Combine(_pluginPath, "meta.json"); - File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + await File.WriteAllTextAsync(metafilePath, JsonSerializer.Serialize(partial, _options)); var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index f56f58c6f..0a153b9cc 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -60,7 +60,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers { Exists = true, FullName = OperatingSystem.IsWindows() ? - "C:\\media\\movies\\Justice League (2017).jpg" + @"C:\media\movies\Justice League (2017).jpg" : "/media/movies/Justice League (2017).jpg" }; directoryService.Setup(x => x.GetFile(_localImageFileMetadata.FullName)) |
