diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index cfb5a6ec25..b522412088 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -86,7 +86,7 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: - - 10.9.10+ + - 10.9.11+ - Master - Unstable - Older* diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 513139ea57..f730408f29 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - name: Setup .NET uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 with: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/init@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/autobuild@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/analyze@6db8d6351fd0be61f9ed8ebd12ccd35dcec51fea # v3.26.11 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index c6e655d080..85f5d00ca8 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -11,7 +11,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -35,7 +35,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index d01c506dbe..82804852a0 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -16,7 +16,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -41,7 +41,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -172,7 +172,7 @@ jobs: strip_components: 1 target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" - name: Move openapi.json (unstable) into place - uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3 + uses: appleboy/ssh-action@25ce8cbbcb08177468c7ff7ec5cbfa236f9341e1 # v1.1.0 with: host: "${{ secrets.REPO_HOST }}" username: "${{ secrets.REPO_USER }}" @@ -234,7 +234,7 @@ jobs: strip_components: 1 target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" - name: Move openapi.json (stable) into place - uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3 + uses: appleboy/ssh-action@25ce8cbbcb08177468c7ff7ec5cbfa236f9341e1 # v1.1.0 with: host: "${{ secrets.REPO_HOST }}" username: "${{ secrets.REPO_USER }}" diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index af8106c0a6..eaa054ca99 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -19,7 +19,7 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 with: @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@e3af7259842d9c814021ea121f85526e0872b25f # v5.3.9 + uses: danielpalme/ReportGenerator-GitHub-Action@b7115d212c0f7814a0cb17fb43ec36983c707ccb # v5.3.10 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 4b469f0d73..d79d437d90 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -24,7 +24,7 @@ jobs: reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -51,7 +51,7 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -128,7 +128,7 @@ jobs: runs-on: ubuntu-latest steps: - name: pull in script - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: repository: jellyfin/jellyfin-triage-script - name: install python diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml index d890765952..ea4c31f613 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -10,7 +10,7 @@ jobs: issues: write steps: - name: pull in script - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: repository: jellyfin/jellyfin-triage-script - name: install python diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml index 575f2d7562..ac8bb2516d 100644 --- a/.github/workflows/release-bump-version.yaml +++ b/.github/workflows/release-bump-version.yaml @@ -33,7 +33,7 @@ jobs: yq-version: v4.9.8 - name: Checkout Repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ env.TAG_BRANCH }} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index cdf8df17fc..a9deb1c4a2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -65,6 +65,7 @@ - [joshuaboniface](https://github.com/joshuaboniface) - [JustAMan](https://github.com/JustAMan) - [justinfenn](https://github.com/justinfenn) + - [JPVenson](https://github.com/JPVenson) - [KerryRJ](https://github.com/KerryRJ) - [Larvitar](https://github.com/Larvitar) - [LeoVerto](https://github.com/LeoVerto) @@ -188,6 +189,9 @@ - [TheMelmacian](https://github.com/TheMelmacian) - [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode) - [pret0rian8](https://github.com/pret0rian) + - [jaina heartles](https://github.com/heartles) + - [oxixes](https://github.com/oxixes) + - [elfalem](https://github.com/elfalem) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index 02937b193a..1bc7c12d0d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,7 +55,7 @@ - + @@ -80,12 +80,12 @@ - + - + \ No newline at end of file diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 51f29cf088..12bc22a6ac 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -141,7 +141,9 @@ namespace Emby.Naming.Video { if (group.Key) { - videos.InsertRange(0, group.OrderByDescending(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); + videos.InsertRange(0, group + .OrderByDescending(x => ResolutionRegex().Match(x.Files[0].FileNameWithoutExtension.ToString()).Value, new AlphanumericComparator()) + .ThenBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); } else { diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index e860105133..91791a1c82 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -20,7 +20,9 @@ namespace Emby.Server.Implementations { PlaylistsAllowDuplicatesKey, bool.FalseString }, { BindToUnixSocketKey, bool.FalseString }, { SqliteCacheSizeKey, "20000" }, - { FfmpegSkipValidationKey, bool.FalseString } + { FfmpegSkipValidationKey, bool.FalseString }, + { FfmpegImgExtractPerfTradeoffKey, bool.FalseString }, + { DetectNetworkChangeKey, bool.TrueString } }; } } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index c7a8421c66..a2aeaf0fcd 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Diagnostics; @@ -212,6 +210,949 @@ namespace Emby.Server.Implementations.Data /// protected override TempStoreMode TempStore => TempStoreMode.Memory; + /// + /// Opens the connection to the database. + /// + public override void Initialize() + { + base.Initialize(); + + const string CreateMediaStreamsTableCommand + = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, DvVersionMajor INT NULL, DvVersionMinor INT NULL, DvProfile INT NULL, DvLevel INT NULL, RpuPresentFlag INT NULL, ElPresentFlag INT NULL, BlPresentFlag INT NULL, DvBlSignalCompatibilityId INT NULL, IsHearingImpaired BIT NULL, Rotation INT NULL, PRIMARY KEY (ItemId, StreamIndex))"; + + const string CreateMediaAttachmentsTableCommand + = "create table if not exists mediaattachments (ItemId GUID, AttachmentIndex INT, Codec TEXT, CodecTag TEXT NULL, Comment TEXT NULL, Filename TEXT NULL, MIMEType TEXT NULL, PRIMARY KEY (ItemId, AttachmentIndex))"; + + string[] queries = + { + "create table if not exists TypedBaseItems (guid GUID primary key NOT NULL, type TEXT NOT NULL, data BLOB NULL, ParentId GUID NULL, Path TEXT NULL)", + + "create table if not exists AncestorIds (ItemId GUID NOT NULL, AncestorId GUID NOT NULL, AncestorIdText TEXT NOT NULL, PRIMARY KEY (ItemId, AncestorId))", + "create index if not exists idx_AncestorIds1 on AncestorIds(AncestorId)", + "create index if not exists idx_AncestorIds5 on AncestorIds(AncestorIdText,ItemId)", + + "create table if not exists ItemValues (ItemId GUID NOT NULL, Type INT NOT NULL, Value TEXT NOT NULL, CleanValue TEXT NOT NULL)", + + "create table if not exists People (ItemId GUID, Name TEXT NOT NULL, Role TEXT, PersonType TEXT, SortOrder int, ListOrder int)", + + "drop index if exists idxPeopleItemId", + "create index if not exists idxPeopleItemId1 on People(ItemId,ListOrder)", + "create index if not exists idxPeopleName on People(Name)", + + "create table if not exists " + ChaptersTableName + " (ItemId GUID, ChapterIndex INT NOT NULL, StartPositionTicks BIGINT NOT NULL, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", + + CreateMediaStreamsTableCommand, + CreateMediaAttachmentsTableCommand, + + "pragma shrink_memory" + }; + + string[] postQueries = + { + "create index if not exists idx_PathTypedBaseItems on TypedBaseItems(Path)", + "create index if not exists idx_ParentIdTypedBaseItems on TypedBaseItems(ParentId)", + + "create index if not exists idx_PresentationUniqueKey on TypedBaseItems(PresentationUniqueKey)", + "create index if not exists idx_GuidTypeIsFolderIsVirtualItem on TypedBaseItems(Guid,Type,IsFolder,IsVirtualItem)", + "create index if not exists idx_CleanNameType on TypedBaseItems(CleanName,Type)", + + // covering index + "create index if not exists idx_TopParentIdGuid on TypedBaseItems(TopParentId,Guid)", + + // series + "create index if not exists idx_TypeSeriesPresentationUniqueKey1 on TypedBaseItems(Type,SeriesPresentationUniqueKey,PresentationUniqueKey,SortName)", + + // series counts + // seriesdateplayed sort order + "create index if not exists idx_TypeSeriesPresentationUniqueKey3 on TypedBaseItems(SeriesPresentationUniqueKey,Type,IsFolder,IsVirtualItem)", + + // live tv programs + "create index if not exists idx_TypeTopParentIdStartDate on TypedBaseItems(Type,TopParentId,StartDate)", + + // covering index for getitemvalues + "create index if not exists idx_TypeTopParentIdGuid on TypedBaseItems(Type,TopParentId,Guid)", + + // used by movie suggestions + "create index if not exists idx_TypeTopParentIdGroup on TypedBaseItems(Type,TopParentId,PresentationUniqueKey)", + "create index if not exists idx_TypeTopParentId5 on TypedBaseItems(TopParentId,IsVirtualItem)", + + // latest items + "create index if not exists idx_TypeTopParentId9 on TypedBaseItems(TopParentId,Type,IsVirtualItem,PresentationUniqueKey,DateCreated)", + "create index if not exists idx_TypeTopParentId8 on TypedBaseItems(TopParentId,IsFolder,IsVirtualItem,PresentationUniqueKey,DateCreated)", + + // resume + "create index if not exists idx_TypeTopParentId7 on TypedBaseItems(TopParentId,MediaType,IsVirtualItem,PresentationUniqueKey)", + + // items by name + "create index if not exists idx_ItemValues6 on ItemValues(ItemId,Type,CleanValue)", + "create index if not exists idx_ItemValues7 on ItemValues(Type,CleanValue,ItemId)", + + // Used to update inherited tags + "create index if not exists idx_ItemValues8 on ItemValues(Type, ItemId, Value)", + + "CREATE INDEX IF NOT EXISTS idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type)", + "CREATE INDEX IF NOT EXISTS idx_PeopleNameListOrder ON People(Name, ListOrder)" + }; + + using (var connection = GetConnection()) + using (var transaction = connection.BeginTransaction()) + { + connection.Execute(string.Join(';', queries)); + + var existingColumnNames = GetColumnNames(connection, "AncestorIds"); + AddColumn(connection, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, "TypedBaseItems"); + + AddColumn(connection, "TypedBaseItems", "Path", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "StartDate", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "EndDate", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ChannelId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsMovie", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CommunityRating", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CustomRating", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IndexNumber", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsLocked", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Name", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "OfficialRating", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "MediaType", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Overview", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ParentIndexNumber", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PremiereDate", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ProductionYear", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ParentId", "GUID", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Genres", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SortName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ForcedSortName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "RunTimeTicks", "BIGINT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateCreated", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateModified", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsSeries", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "EpisodeTitle", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsRepeat", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PreferredMetadataLanguage", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PreferredMetadataCountryCode", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateLastRefreshed", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateLastSaved", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsInMixedFolder", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "LockedFields", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Studios", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Audio", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExternalServiceId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Tags", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsFolder", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "InheritedParentalRatingValue", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "UnratedType", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "TopParentId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "TrailerTypes", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CriticRating", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CleanName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PresentationUniqueKey", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "OriginalTitle", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Album", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "LUFS", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "NormalizationGain", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeasonName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeasonId", "GUID", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeriesId", "GUID", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExternalSeriesId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Tagline", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ProviderIds", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Images", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ProductionLocations", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExtraIds", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "TotalBitrate", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExtraType", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Artists", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "AlbumArtists", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExternalId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeriesPresentationUniqueKey", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ShowId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "OwnerId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Width", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Height", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Size", "BIGINT", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, "ItemValues"); + AddColumn(connection, "ItemValues", "CleanValue", "Text", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, ChaptersTableName); + AddColumn(connection, ChaptersTableName, "ImageDateModified", "DATETIME", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, "MediaStreams"); + AddColumn(connection, "MediaStreams", "IsAvc", "BIT", existingColumnNames); + AddColumn(connection, "MediaStreams", "TimeBase", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "CodecTimeBase", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "Title", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "NalLengthSize", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "Comment", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "CodecTag", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "PixelFormat", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "BitDepth", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "RefFrames", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "KeyFrames", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "IsAnamorphic", "BIT", existingColumnNames); + + AddColumn(connection, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); + + AddColumn(connection, "MediaStreams", "DvVersionMajor", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvVersionMinor", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvProfile", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvLevel", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "RpuPresentFlag", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "ElPresentFlag", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "BlPresentFlag", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvBlSignalCompatibilityId", "INT", existingColumnNames); + + AddColumn(connection, "MediaStreams", "IsHearingImpaired", "BIT", existingColumnNames); + + AddColumn(connection, "MediaStreams", "Rotation", "INT", existingColumnNames); + + connection.Execute(string.Join(';', postQueries)); + + transaction.Commit(); + } + } + + /// + public void SaveImages(BaseItem item) + { + ArgumentNullException.ThrowIfNull(item); + + CheckDisposed(); + + var images = SerializeImages(item.ImageInfos); + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + using var saveImagesStatement = PrepareStatement(connection, "Update TypedBaseItems set Images=@Images where guid=@Id"); + saveImagesStatement.TryBind("@Id", item.Id); + saveImagesStatement.TryBind("@Images", images); + + saveImagesStatement.ExecuteNonQuery(); + transaction.Commit(); + } + + /// + /// Saves the items. + /// + /// The items. + /// The cancellation token. + /// + /// or is null. + /// + public void SaveItems(IReadOnlyList items, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(items); + + cancellationToken.ThrowIfCancellationRequested(); + + CheckDisposed(); + + var itemsLen = items.Count; + var tuples = new ValueTuple, BaseItem, string, List>[itemsLen]; + for (int i = 0; i < itemsLen; i++) + { + var item = items[i]; + var ancestorIds = item.SupportsAncestors ? + item.GetAncestorIds().Distinct().ToList() : + null; + + var topParent = item.GetTopParent(); + + var userdataKey = item.GetUserDataKeys().FirstOrDefault(); + var inheritedTags = item.GetInheritedTags(); + + tuples[i] = (item, ancestorIds, topParent, userdataKey, inheritedTags); + } + + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + SaveItemsInTransaction(connection, tuples); + transaction.Commit(); + } + + private void SaveItemsInTransaction(ManagedConnection db, IEnumerable<(BaseItem Item, List AncestorIds, BaseItem TopParent, string UserDataKey, List InheritedTags)> tuples) + { + using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText)) + using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId")) + { + var requiresReset = false; + foreach (var tuple in tuples) + { + if (requiresReset) + { + saveItemStatement.Parameters.Clear(); + deleteAncestorsStatement.Parameters.Clear(); + } + + var item = tuple.Item; + var topParent = tuple.TopParent; + var userDataKey = tuple.UserDataKey; + + SaveItem(item, topParent, userDataKey, saveItemStatement); + + var inheritedTags = tuple.InheritedTags; + + if (item.SupportsAncestors) + { + UpdateAncestors(item.Id, tuple.AncestorIds, db, deleteAncestorsStatement); + } + + UpdateItemValues(item.Id, GetItemValuesToSave(item, inheritedTags), db); + + requiresReset = true; + } + } + } + + private string GetPathToSave(string path) + { + if (path is null) + { + return null; + } + + return _appHost.ReverseVirtualPath(path); + } + + private string RestorePath(string path) + { + return _appHost.ExpandVirtualPath(path); + } + + private void SaveItem(BaseItem item, BaseItem topParent, string userDataKey, SqliteCommand saveItemStatement) + { + Type type = item.GetType(); + + saveItemStatement.TryBind("@guid", item.Id); + saveItemStatement.TryBind("@type", type.FullName); + + if (TypeRequiresDeserialization(type)) + { + saveItemStatement.TryBind("@data", JsonSerializer.SerializeToUtf8Bytes(item, type, _jsonOptions), true); + } + else + { + saveItemStatement.TryBindNull("@data"); + } + + saveItemStatement.TryBind("@Path", GetPathToSave(item.Path)); + + if (item is IHasStartDate hasStartDate) + { + saveItemStatement.TryBind("@StartDate", hasStartDate.StartDate); + } + else + { + saveItemStatement.TryBindNull("@StartDate"); + } + + if (item.EndDate.HasValue) + { + saveItemStatement.TryBind("@EndDate", item.EndDate.Value); + } + else + { + saveItemStatement.TryBindNull("@EndDate"); + } + + saveItemStatement.TryBind("@ChannelId", item.ChannelId.IsEmpty() ? null : item.ChannelId.ToString("N", CultureInfo.InvariantCulture)); + + if (item is IHasProgramAttributes hasProgramAttributes) + { + saveItemStatement.TryBind("@IsMovie", hasProgramAttributes.IsMovie); + saveItemStatement.TryBind("@IsSeries", hasProgramAttributes.IsSeries); + saveItemStatement.TryBind("@EpisodeTitle", hasProgramAttributes.EpisodeTitle); + saveItemStatement.TryBind("@IsRepeat", hasProgramAttributes.IsRepeat); + } + else + { + saveItemStatement.TryBindNull("@IsMovie"); + saveItemStatement.TryBindNull("@IsSeries"); + saveItemStatement.TryBindNull("@EpisodeTitle"); + saveItemStatement.TryBindNull("@IsRepeat"); + } + + saveItemStatement.TryBind("@CommunityRating", item.CommunityRating); + saveItemStatement.TryBind("@CustomRating", item.CustomRating); + saveItemStatement.TryBind("@IndexNumber", item.IndexNumber); + saveItemStatement.TryBind("@IsLocked", item.IsLocked); + saveItemStatement.TryBind("@Name", item.Name); + saveItemStatement.TryBind("@OfficialRating", item.OfficialRating); + saveItemStatement.TryBind("@MediaType", item.MediaType.ToString()); + saveItemStatement.TryBind("@Overview", item.Overview); + saveItemStatement.TryBind("@ParentIndexNumber", item.ParentIndexNumber); + saveItemStatement.TryBind("@PremiereDate", item.PremiereDate); + saveItemStatement.TryBind("@ProductionYear", item.ProductionYear); + + var parentId = item.ParentId; + if (parentId.IsEmpty()) + { + saveItemStatement.TryBindNull("@ParentId"); + } + else + { + saveItemStatement.TryBind("@ParentId", parentId); + } + + if (item.Genres.Length > 0) + { + saveItemStatement.TryBind("@Genres", string.Join('|', item.Genres)); + } + else + { + saveItemStatement.TryBindNull("@Genres"); + } + + saveItemStatement.TryBind("@InheritedParentalRatingValue", item.InheritedParentalRatingValue); + + saveItemStatement.TryBind("@SortName", item.SortName); + + saveItemStatement.TryBind("@ForcedSortName", item.ForcedSortName); + + saveItemStatement.TryBind("@RunTimeTicks", item.RunTimeTicks); + saveItemStatement.TryBind("@Size", item.Size); + + saveItemStatement.TryBind("@DateCreated", item.DateCreated); + saveItemStatement.TryBind("@DateModified", item.DateModified); + + saveItemStatement.TryBind("@PreferredMetadataLanguage", item.PreferredMetadataLanguage); + saveItemStatement.TryBind("@PreferredMetadataCountryCode", item.PreferredMetadataCountryCode); + + if (item.Width > 0) + { + saveItemStatement.TryBind("@Width", item.Width); + } + else + { + saveItemStatement.TryBindNull("@Width"); + } + + if (item.Height > 0) + { + saveItemStatement.TryBind("@Height", item.Height); + } + else + { + saveItemStatement.TryBindNull("@Height"); + } + + if (item.DateLastRefreshed != default(DateTime)) + { + saveItemStatement.TryBind("@DateLastRefreshed", item.DateLastRefreshed); + } + else + { + saveItemStatement.TryBindNull("@DateLastRefreshed"); + } + + if (item.DateLastSaved != default(DateTime)) + { + saveItemStatement.TryBind("@DateLastSaved", item.DateLastSaved); + } + else + { + saveItemStatement.TryBindNull("@DateLastSaved"); + } + + saveItemStatement.TryBind("@IsInMixedFolder", item.IsInMixedFolder); + + if (item.LockedFields.Length > 0) + { + saveItemStatement.TryBind("@LockedFields", string.Join('|', item.LockedFields)); + } + else + { + saveItemStatement.TryBindNull("@LockedFields"); + } + + if (item.Studios.Length > 0) + { + saveItemStatement.TryBind("@Studios", string.Join('|', item.Studios)); + } + else + { + saveItemStatement.TryBindNull("@Studios"); + } + + if (item.Audio.HasValue) + { + saveItemStatement.TryBind("@Audio", item.Audio.Value.ToString()); + } + else + { + saveItemStatement.TryBindNull("@Audio"); + } + + if (item is LiveTvChannel liveTvChannel) + { + saveItemStatement.TryBind("@ExternalServiceId", liveTvChannel.ServiceName); + } + else + { + saveItemStatement.TryBindNull("@ExternalServiceId"); + } + + if (item.Tags.Length > 0) + { + saveItemStatement.TryBind("@Tags", string.Join('|', item.Tags)); + } + else + { + saveItemStatement.TryBindNull("@Tags"); + } + + saveItemStatement.TryBind("@IsFolder", item.IsFolder); + + saveItemStatement.TryBind("@UnratedType", item.GetBlockUnratedType().ToString()); + + if (topParent is null) + { + saveItemStatement.TryBindNull("@TopParentId"); + } + else + { + saveItemStatement.TryBind("@TopParentId", topParent.Id.ToString("N", CultureInfo.InvariantCulture)); + } + + if (item is Trailer trailer && trailer.TrailerTypes.Length > 0) + { + saveItemStatement.TryBind("@TrailerTypes", string.Join('|', trailer.TrailerTypes)); + } + else + { + saveItemStatement.TryBindNull("@TrailerTypes"); + } + + saveItemStatement.TryBind("@CriticRating", item.CriticRating); + + if (string.IsNullOrWhiteSpace(item.Name)) + { + saveItemStatement.TryBindNull("@CleanName"); + } + else + { + saveItemStatement.TryBind("@CleanName", GetCleanValue(item.Name)); + } + + saveItemStatement.TryBind("@PresentationUniqueKey", item.PresentationUniqueKey); + saveItemStatement.TryBind("@OriginalTitle", item.OriginalTitle); + + if (item is Video video) + { + saveItemStatement.TryBind("@PrimaryVersionId", video.PrimaryVersionId); + } + else + { + saveItemStatement.TryBindNull("@PrimaryVersionId"); + } + + if (item is Folder folder && folder.DateLastMediaAdded.HasValue) + { + saveItemStatement.TryBind("@DateLastMediaAdded", folder.DateLastMediaAdded.Value); + } + else + { + saveItemStatement.TryBindNull("@DateLastMediaAdded"); + } + + saveItemStatement.TryBind("@Album", item.Album); + saveItemStatement.TryBind("@LUFS", item.LUFS); + saveItemStatement.TryBind("@NormalizationGain", item.NormalizationGain); + saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem); + + if (item is IHasSeries hasSeriesName) + { + saveItemStatement.TryBind("@SeriesName", hasSeriesName.SeriesName); + } + else + { + saveItemStatement.TryBindNull("@SeriesName"); + } + + if (string.IsNullOrWhiteSpace(userDataKey)) + { + saveItemStatement.TryBindNull("@UserDataKey"); + } + else + { + saveItemStatement.TryBind("@UserDataKey", userDataKey); + } + + if (item is Episode episode) + { + saveItemStatement.TryBind("@SeasonName", episode.SeasonName); + + var nullableSeasonId = episode.SeasonId.IsEmpty() ? (Guid?)null : episode.SeasonId; + + saveItemStatement.TryBind("@SeasonId", nullableSeasonId); + } + else + { + saveItemStatement.TryBindNull("@SeasonName"); + saveItemStatement.TryBindNull("@SeasonId"); + } + + if (item is IHasSeries hasSeries) + { + var nullableSeriesId = hasSeries.SeriesId.IsEmpty() ? (Guid?)null : hasSeries.SeriesId; + + saveItemStatement.TryBind("@SeriesId", nullableSeriesId); + saveItemStatement.TryBind("@SeriesPresentationUniqueKey", hasSeries.SeriesPresentationUniqueKey); + } + else + { + saveItemStatement.TryBindNull("@SeriesId"); + saveItemStatement.TryBindNull("@SeriesPresentationUniqueKey"); + } + + saveItemStatement.TryBind("@ExternalSeriesId", item.ExternalSeriesId); + saveItemStatement.TryBind("@Tagline", item.Tagline); + + saveItemStatement.TryBind("@ProviderIds", SerializeProviderIds(item.ProviderIds)); + saveItemStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); + + if (item.ProductionLocations.Length > 0) + { + saveItemStatement.TryBind("@ProductionLocations", string.Join('|', item.ProductionLocations)); + } + else + { + saveItemStatement.TryBindNull("@ProductionLocations"); + } + + if (item.ExtraIds.Length > 0) + { + saveItemStatement.TryBind("@ExtraIds", string.Join('|', item.ExtraIds)); + } + else + { + saveItemStatement.TryBindNull("@ExtraIds"); + } + + saveItemStatement.TryBind("@TotalBitrate", item.TotalBitrate); + if (item.ExtraType.HasValue) + { + saveItemStatement.TryBind("@ExtraType", item.ExtraType.Value.ToString()); + } + else + { + saveItemStatement.TryBindNull("@ExtraType"); + } + + string artists = null; + if (item is IHasArtist hasArtists && hasArtists.Artists.Count > 0) + { + artists = string.Join('|', hasArtists.Artists); + } + + saveItemStatement.TryBind("@Artists", artists); + + string albumArtists = null; + if (item is IHasAlbumArtist hasAlbumArtists + && hasAlbumArtists.AlbumArtists.Count > 0) + { + albumArtists = string.Join('|', hasAlbumArtists.AlbumArtists); + } + + saveItemStatement.TryBind("@AlbumArtists", albumArtists); + saveItemStatement.TryBind("@ExternalId", item.ExternalId); + + if (item is LiveTvProgram program) + { + saveItemStatement.TryBind("@ShowId", program.ShowId); + } + else + { + saveItemStatement.TryBindNull("@ShowId"); + } + + Guid ownerId = item.OwnerId; + if (ownerId.IsEmpty()) + { + saveItemStatement.TryBindNull("@OwnerId"); + } + else + { + saveItemStatement.TryBind("@OwnerId", ownerId); + } + + saveItemStatement.ExecuteNonQuery(); + } + + internal static string SerializeProviderIds(Dictionary providerIds) + { + StringBuilder str = new StringBuilder(); + foreach (var i in providerIds) + { + // Ideally we shouldn't need this IsNullOrWhiteSpace check, + // but we're seeing some cases of bad data slip through + if (string.IsNullOrWhiteSpace(i.Value)) + { + continue; + } + + str.Append(i.Key) + .Append('=') + .Append(i.Value) + .Append('|'); + } + + if (str.Length == 0) + { + return null; + } + + str.Length -= 1; // Remove last | + return str.ToString(); + } + + internal static void DeserializeProviderIds(string value, IHasProviderIds item) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + foreach (var part in value.SpanSplit('|')) + { + var providerDelimiterIndex = part.IndexOf('='); + // Don't let empty values through + if (providerDelimiterIndex != -1 && part.Length != providerDelimiterIndex + 1) + { + item.SetProviderId(part[..providerDelimiterIndex].ToString(), part[(providerDelimiterIndex + 1)..].ToString()); + } + } + } + + internal string SerializeImages(ItemImageInfo[] images) + { + if (images.Length == 0) + { + return null; + } + + StringBuilder str = new StringBuilder(); + foreach (var i in images) + { + if (string.IsNullOrWhiteSpace(i.Path)) + { + continue; + } + + AppendItemImageInfo(str, i); + str.Append('|'); + } + + str.Length -= 1; // Remove last | + return str.ToString(); + } + + internal ItemImageInfo[] DeserializeImages(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty(); + } + + // TODO The following is an ugly performance optimization, but it's extremely unlikely that the data in the database would be malformed + var valueSpan = value.AsSpan(); + var count = valueSpan.Count('|') + 1; + + var position = 0; + var result = new ItemImageInfo[count]; + foreach (var part in valueSpan.Split('|')) + { + var image = ItemImageInfoFromValueString(part); + + if (image is not null) + { + result[position++] = image; + } + } + + if (position == count) + { + return result; + } + + if (position == 0) + { + return Array.Empty(); + } + + // Extremely unlikely, but somehow one or more of the image strings were malformed. Cut the array. + return result[..position]; + } + + private void AppendItemImageInfo(StringBuilder bldr, ItemImageInfo image) + { + const char Delimiter = '*'; + + var path = image.Path ?? string.Empty; + + bldr.Append(GetPathToSave(path)) + .Append(Delimiter) + .Append(image.DateModified.Ticks) + .Append(Delimiter) + .Append(image.Type) + .Append(Delimiter) + .Append(image.Width) + .Append(Delimiter) + .Append(image.Height); + + var hash = image.BlurHash; + if (!string.IsNullOrEmpty(hash)) + { + bldr.Append(Delimiter) + // Replace delimiters with other characters. + // This can be removed when we migrate to a proper DB. + .Append(hash.Replace(Delimiter, '/').Replace('|', '\\')); + } + } + + internal ItemImageInfo ItemImageInfoFromValueString(ReadOnlySpan value) + { + const char Delimiter = '*'; + + var nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan path = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + return null; + } + + ReadOnlySpan dateModified = value[..nextSegment]; + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan imageType = value[..nextSegment]; + + var image = new ItemImageInfo + { + Path = RestorePath(path.ToString()) + }; + + if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks) + && ticks >= DateTime.MinValue.Ticks + && ticks <= DateTime.MaxValue.Ticks) + { + image.DateModified = new DateTime(ticks, DateTimeKind.Utc); + } + else + { + return null; + } + + if (Enum.TryParse(imageType, true, out ImageType type)) + { + image.Type = type; + } + else + { + return null; + } + + // Optional parameters: width*height*blurhash + if (nextSegment + 1 < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1 || nextSegment == value.Length) + { + return image; + } + + ReadOnlySpan widthSpan = value[..nextSegment]; + + value = value[(nextSegment + 1)..]; + nextSegment = value.IndexOf(Delimiter); + if (nextSegment == -1) + { + nextSegment = value.Length; + } + + ReadOnlySpan heightSpan = value[..nextSegment]; + + if (int.TryParse(widthSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) + && int.TryParse(heightSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var height)) + { + image.Width = width; + image.Height = height; + } + + if (nextSegment < value.Length - 1) + { + value = value[(nextSegment + 1)..]; + var length = value.Length; + + Span blurHashSpan = stackalloc char[length]; + for (int i = 0; i < length; i++) + { + var c = value[i]; + blurHashSpan[i] = c switch + { + '/' => Delimiter, + '\\' => '|', + _ => c + }; + } + + image.BlurHash = new string(blurHashSpan); + } + } + + return image; + } + + /// + /// Internal retrieve from items or users table. + /// + /// The id. + /// BaseItem. + /// is null. + /// is . + public BaseItem RetrieveItem(Guid id) + { + if (id.IsEmpty()) + { + throw new ArgumentException("Guid can't be empty", nameof(id)); + } + + CheckDisposed(); + + using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) + { + statement.TryBind("@guid", id); + + foreach (var row in statement.ExecuteQuery()) + { + return GetItem(row, new InternalItemsQuery()); + } + } + + return null; + } + private bool TypeRequiresDeserialization(Type type) { if (_config.Configuration.SkipDeserializationForBasicTypes) @@ -694,9 +1635,6 @@ namespace Emby.Server.Implementations.Data if (query.SearchTerm.Length > 1) { builder.Append("+ ((CleanName like @SearchTermContains or (OriginalTitle not null and OriginalTitle like @SearchTermContains)) * 10)"); - builder.Append("+ (SELECT COUNT(1) * 1 from ItemValues where ItemId=Guid and CleanValue like @SearchTermContains)"); - builder.Append("+ (SELECT COUNT(1) * 2 from ItemValues where ItemId=Guid and CleanValue like @SearchTermStartsWith)"); - builder.Append("+ (SELECT COUNT(1) * 10 from ItemValues where ItemId=Guid and CleanValue like @SearchTermEquals)"); } builder.Append(") as SearchScore"); @@ -727,11 +1665,6 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@SearchTermContains", "%" + searchTerm + "%"); } - - if (commandText.Contains("@SearchTermEquals", StringComparison.OrdinalIgnoreCase)) - { - statement.TryBind("@SearchTermEquals", searchTerm); - } } private void BindSimilarParams(InternalItemsQuery query, SqliteCommand statement) @@ -797,6 +1730,7 @@ namespace Emby.Server.Implementations.Data return string.Empty; } + /// public int GetCount(InternalItemsQuery query) { ArgumentNullException.ThrowIfNull(query); @@ -844,6 +1778,7 @@ namespace Emby.Server.Implementations.Data } } + /// public List GetItemList(InternalItemsQuery query) { ArgumentNullException.ThrowIfNull(query); @@ -997,6 +1932,7 @@ namespace Emby.Server.Implementations.Data items.Add(newItem); } + /// public QueryResult GetItems(InternalItemsQuery query) { ArgumentNullException.ThrowIfNull(query); @@ -1241,6 +2177,7 @@ namespace Emby.Server.Implementations.Data }; } + /// public List GetItemIdsList(InternalItemsQuery query) { ArgumentNullException.ThrowIfNull(query); @@ -2557,6 +3494,15 @@ namespace Emby.Server.Implementations.Data OR (select CleanValue from ItemValues where ItemId=ParentId and Type=6 and CleanValue in ({includedTags})) is not null) """); } + + // A playlist should be accessible to its owner regardless of allowed tags. + else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) + { + whereClauses.Add($""" + ((select CleanValue from ItemValues where ItemId=Guid and Type=6 and CleanValue in ({includedTags})) is not null + OR data like @PlaylistOwnerUserId) + """); + } else { whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)"); @@ -2568,6 +3514,11 @@ namespace Emby.Server.Implementations.Data { statement.TryBind(paramName + index, GetCleanValue(query.IncludeInheritedTags[index])); } + + if (query.User is not null) + { + statement.TryBind("@PlaylistOwnerUserId", $"""%"OwnerUserId":"{query.User.Id.ToString("N")}"%"""); + } } } @@ -2785,6 +3736,7 @@ namespace Emby.Server.Implementations.Data || query.IncludeItemTypes.Contains(BaseItemKind.Season); } + /// public void UpdateInheritedValues() { const string Statements = """ @@ -2801,6 +3753,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type transaction.Commit(); } + /// public void DeleteItem(Guid id) { if (id.IsEmpty()) @@ -2843,6 +3796,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } } + /// public List GetPeopleNames(InternalPeopleQuery query) { ArgumentNullException.ThrowIfNull(query); @@ -2881,6 +3835,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return list; } + /// public List GetPeople(InternalPeopleQuery query) { ArgumentNullException.ThrowIfNull(query); @@ -3040,46 +3995,55 @@ AND Type = @InternalPersonType)"); } } + /// public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query) { return GetItemValues(query, new[] { 0, 1 }, typeof(MusicArtist).FullName); } + /// public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query) { return GetItemValues(query, new[] { 0 }, typeof(MusicArtist).FullName); } + /// public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) { return GetItemValues(query, new[] { 1 }, typeof(MusicArtist).FullName); } + /// public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery query) { return GetItemValues(query, new[] { 3 }, typeof(Studio).FullName); } + /// public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery query) { return GetItemValues(query, new[] { 2 }, typeof(Genre).FullName); } + /// public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery query) { return GetItemValues(query, new[] { 2 }, typeof(MusicGenre).FullName); } + /// public List GetStudioNames() { return GetItemValueNames(new[] { 3 }, Array.Empty(), Array.Empty()); } + /// public List GetAllArtistNames() { return GetItemValueNames(new[] { 0, 1 }, Array.Empty(), Array.Empty()); } + /// public List GetMusicGenreNames() { return GetItemValueNames( @@ -3094,6 +4058,7 @@ AND Type = @InternalPersonType)"); Array.Empty()); } + /// public List GetGenreNames() { return GetItemValueNames( @@ -3571,6 +4536,7 @@ AND Type = @InternalPersonType)"); } } + /// public void UpdatePeople(Guid itemId, List people) { if (itemId.IsEmpty()) @@ -3672,6 +4638,7 @@ AND Type = @InternalPersonType)"); return item; } + /// public List GetMediaStreams(MediaStreamQuery query) { CheckDisposed(); @@ -3720,6 +4687,7 @@ AND Type = @InternalPersonType)"); } } + /// public void SaveMediaStreams(Guid id, IReadOnlyList streams, CancellationToken cancellationToken) { CheckDisposed(); @@ -4074,6 +5042,7 @@ AND Type = @InternalPersonType)"); return item; } + /// public List GetMediaAttachments(MediaAttachmentQuery query) { CheckDisposed(); @@ -4109,6 +5078,7 @@ AND Type = @InternalPersonType)"); return list; } + /// public void SaveMediaAttachments( Guid id, IReadOnlyList attachments, diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 31617d1a5f..6af2a553d6 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -314,6 +314,12 @@ namespace Emby.Server.Implementations.IO var ex = e.GetException(); var dw = (FileSystemWatcher)sender; + if (ex is UnauthorizedAccessException unauthorizedAccessException) + { + _logger.LogError(unauthorizedAccessException, "Permission error for Directory watcher: {Path}", dw.Path); + return; + } + _logger.LogError(ex, "Error in Directory watcher for: {Path}", dw.Path); DisposeWatcher(dw, true); diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 28bb29df85..4b68f21d55 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -149,6 +149,26 @@ namespace Emby.Server.Implementations.IO } } + /// + public 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.Combine(destination, Path.GetFileName(file)), true); + } + + Directory.Delete(source, true); + } + } + /// /// Returns a object for the specified file or directory path. /// @@ -327,11 +347,7 @@ namespace Emby.Server.Implementations.IO } } - /// - /// Gets the creation time UTC. - /// - /// The path. - /// DateTime. + /// public virtual DateTime GetCreationTimeUtc(string path) { return GetCreationTimeUtc(GetFileSystemInfo(path)); @@ -368,11 +384,7 @@ namespace Emby.Server.Implementations.IO } } - /// - /// Gets the last write time UTC. - /// - /// The path. - /// DateTime. + /// public virtual DateTime GetLastWriteTimeUtc(string path) { return GetLastWriteTimeUtc(GetFileSystemInfo(path)); @@ -446,11 +458,7 @@ namespace Emby.Server.Implementations.IO File.SetAttributes(path, attributes); } - /// - /// Swaps the files. - /// - /// The file1. - /// The file2. + /// public virtual void SwapFiles(string file1, string file2) { ArgumentException.ThrowIfNullOrEmpty(file1); diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index a2301c8aed..bb45dd87e9 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library "**/lost+found/**", "**/lost+found", + // Trickplay files + "**/*.trickplay", + "**/*.trickplay/**", + // WMC temp recording directories that will constantly be written to "**/TempRec/**", "**/TempRec", diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 48d24385e9..28f7ed6598 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2725,33 +2725,9 @@ namespace Emby.Server.Implementations.Library public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem) { - string? newPath; - if (ownerItem is not null) - { - var libraryOptions = GetLibraryOptions(ownerItem); - if (libraryOptions is not null) - { - foreach (var pathInfo in libraryOptions.PathInfos) - { - if (path.TryReplaceSubPath(pathInfo.Path, pathInfo.NetworkPath, out newPath)) - { - return newPath; - } - } - } - } - - var metadataPath = _configurationManager.Configuration.MetadataPath; - var metadataNetworkPath = _configurationManager.Configuration.MetadataNetworkPath; - - if (path.TryReplaceSubPath(metadataPath, metadataNetworkPath, out newPath)) - { - return newPath; - } - foreach (var map in _configurationManager.Configuration.PathSubstitutions) { - if (path.TryReplaceSubPath(map.From, map.To, out newPath)) + if (path.TryReplaceSubPath(map.From, map.To, out var newPath)) { return newPath; } @@ -3070,15 +3046,6 @@ namespace Emby.Server.Implementations.Library SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions); - foreach (var originalPathInfo in libraryOptions.PathInfos) - { - if (string.Equals(mediaPath.Path, originalPathInfo.Path, StringComparison.Ordinal)) - { - originalPathInfo.NetworkPath = mediaPath.NetworkPath; - break; - } - } - CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions); } diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 9172af516e..97aa0ca58c 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -129,5 +129,11 @@ "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання", "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць.", "TaskAudioNormalizationDescription": "Сканіруе файлы на прадмет нармалізацыі гуку.", - "TaskAudioNormalization": "Нармалізацыя гуку" + "TaskAudioNormalization": "Нармалізацыя гуку", + "TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Перамяшчае існуючыя файлы trickplay у адпаведнасці з наладамі бібліятэкі.", + "TaskDownloadMissingLyrics": "Спампаваць зніклыя тэксты песень", + "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песень", + "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", + "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay" } diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 2998489b57..6b3b78fa12 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -130,5 +130,7 @@ "TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.", "TaskCleanCollectionsAndPlaylists": "Neteja col·leccions i llistes de reproducció", "TaskAudioNormalization": "Normalització d'Àudio", - "TaskAudioNormalizationDescription": "Escaneja arxius per dades de normalització d'àudio." + "TaskAudioNormalizationDescription": "Escaneja arxius per dades de normalització d'àudio.", + "TaskDownloadMissingLyricsDescription": "Baixar lletres de les cançons", + "TaskDownloadMissingLyrics": "Baixar lletres que falten" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index ad9e555a3c..ba2e2700da 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -132,5 +132,9 @@ "TaskAudioNormalization": "Normalizace zvuku", "TaskAudioNormalizationDescription": "Skenovat soubory za účelem normalizace zvuku.", "TaskDownloadMissingLyrics": "Stáhnout chybějící texty k písni", - "TaskDownloadMissingLyricsDescription": "Stáhne texty k písni" + "TaskDownloadMissingLyricsDescription": "Stáhne texty k písni", + "TaskExtractMediaSegments": "Skenování segmentů médií", + "TaskExtractMediaSegmentsDescription": "Extrahuje či získá segmenty médií pomocí zásuvných modulů MediaSegment.", + "TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay", + "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny." } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index bbb162c776..51c9e87d5a 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -132,5 +132,9 @@ "TaskAudioNormalization": "Audio Normalisierung", "TaskAudioNormalizationDescription": "Durchsucht Dateien nach Audionormalisierungsdaten.", "TaskDownloadMissingLyricsDescription": "Lädt Songtexte herunter", - "TaskDownloadMissingLyrics": "Fehlende Songtexte herunterladen" + "TaskDownloadMissingLyrics": "Fehlende Songtexte herunterladen", + "TaskExtractMediaSegments": "Scanne Mediensegmente", + "TaskExtractMediaSegmentsDescription": "Extrahiert oder empfängt Mediensegmente von Plugins die Mediensegmente nutzen.", + "TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren", + "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben." } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 65df1e45b1..ca52ffb143 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -132,5 +132,9 @@ "TaskAudioNormalization": "Audio Normalisation", "TaskAudioNormalizationDescription": "Scans files for audio normalisation data.", "TaskDownloadMissingLyrics": "Download missing lyrics", - "TaskDownloadMissingLyricsDescription": "Downloads lyrics for songs" + "TaskDownloadMissingLyricsDescription": "Downloads lyrics for songs", + "TaskExtractMediaSegments": "Media Segment Scan", + "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.", + "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", + "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings." } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index d1410ef5e6..9702ab7123 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -131,5 +131,9 @@ "TaskKeyframeExtractor": "Keyframe Extractor", "TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.", "TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists", - "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist." + "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist.", + "TaskExtractMediaSegments": "Media Segment Scan", + "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.", + "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", + "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings." } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index b926d9d30e..f2f657b049 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -132,5 +132,9 @@ "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción", "TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de colecciones y listas de reproducción que ya no existen.", "TaskDownloadMissingLyrics": "Descargar letra faltante", - "TaskDownloadMissingLyricsDescription": "Descarga letras de canciones" + "TaskDownloadMissingLyricsDescription": "Descarga letras de canciones", + "TaskExtractMediaSegments": "Escanear Segmentos de Media", + "TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medio de plugins habilitados para MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.", + "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay" } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index 075bcc9a44..3b2bb70a95 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -102,7 +102,7 @@ "Forced": "Sunnitud", "Folders": "Kaustad", "Favorites": "Lemmikud", - "FailedLoginAttemptWithUserName": "{0} - sisselogimine nurjus", + "FailedLoginAttemptWithUserName": "Sisselogimine nurjus aadressilt {0}", "DeviceOnlineWithName": "{0} on ühendatud", "DeviceOfflineWithName": "{0} katkestas ühenduse", "Default": "Vaikimisi", @@ -129,5 +129,11 @@ "TaskAudioNormalization": "Heli Normaliseerimine", "TaskAudioNormalizationDescription": "Skaneerib faile heli normaliseerimise andmete jaoks.", "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.", - "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid" + "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid", + "TaskDownloadMissingLyrics": "Lae alla puuduolev lüürika", + "TaskDownloadMissingLyricsDescription": "Lae lauludele alla lüürika", + "TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.", + "TaskExtractMediaSegments": "Meediasegmentide skaneerimine", + "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.", + "TaskMoveTrickplayImages": "Migreeri trickplay piltide asukoht" } diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json index b0ddec1046..ff14c13678 100644 --- a/Emby.Server.Implementations/Localization/Core/fa.json +++ b/Emby.Server.Implementations/Localization/Core/fa.json @@ -132,5 +132,9 @@ "TaskAudioNormalizationDescription": "بررسی فایل برای داده‌های نرمال کردن صدا.", "TaskDownloadMissingLyrics": "دانلود متن‌های ناموجود", "TaskDownloadMissingLyricsDescription": "دانلود متن شعر‌ها", - "TaskAudioNormalization": "نرمال کردن صدا" + "TaskAudioNormalization": "نرمال کردن صدا", + "TaskExtractMediaSegments": "بررسی بخش محتوا", + "TaskExtractMediaSegmentsDescription": "بخش‌های محتوا را از افزونه‌های مربوط استخراح می‌کند.", + "TaskMoveTrickplayImages": "جابه‌جایی عکس‌های Trickplay", + "TaskMoveTrickplayImagesDescription": "داده‌های Trickplay را با توجه به تنظیمات کتاب‌خانه جابه‌جا می‌کند." } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index dced61c5ed..8a88cf28e9 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -129,5 +129,6 @@ "TaskCleanCollectionsAndPlaylistsDescription": "Poistaa kohteet kokoelmista ja soittolistoista joita ei ole enää olemassa.", "TaskCleanCollectionsAndPlaylists": "Puhdista kokoelmat ja soittolistat", "TaskAudioNormalization": "Äänenvoimakkuuden normalisointi", - "TaskAudioNormalizationDescription": "Etsii tiedostoista äänenvoimakkuuden normalisointitietoja." + "TaskAudioNormalizationDescription": "Etsii tiedostoista äänenvoimakkuuden normalisointitietoja.", + "TaskDownloadMissingLyrics": "Lataa puuttuva lyriikka" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 1dba78adde..3caf8b5478 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -132,5 +132,9 @@ "TaskAudioNormalization": "Normalisation audio", "TaskAudioNormalizationDescription": "Analyse les fichiers à la recherche de données de normalisation audio.", "TaskDownloadMissingLyricsDescription": "Téléchargement des paroles des chansons", - "TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes" + "TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes", + "TaskExtractMediaSegments": "Analyse des segments de média", + "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay", + "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque." } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index 76a98aa54b..3ba3e6679e 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -1,7 +1,7 @@ { "Albums": "Álbumes", - "Collections": "Colecións", - "ChapterNameValue": "Capítulos {0}", + "Collections": "Coleccións", + "ChapterNameValue": "Capítulo {0}", "Channels": "Canles", "CameraImageUploadedFrom": "Cargouse unha nova imaxe da cámara desde {0}", "Books": "Libros", diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index c8e0364248..af57b1693e 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -60,7 +60,7 @@ "NotificationOptionUserLockedOut": "משתמש ננעל", "NotificationOptionVideoPlayback": "ניגון וידאו החל", "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק", - "Photos": "תמונות", + "Photos": "צילומים", "Playlists": "רשימות נגינה", "Plugin": "תוסף", "PluginInstalledWithName": "{0} הותקן", @@ -130,5 +130,11 @@ "TaskAudioNormalization": "נרמול שמע", "TaskCleanCollectionsAndPlaylistsDescription": "מנקה פריטים לא קיימים מאוספים ורשימות השמעה.", "TaskAudioNormalizationDescription": "מחפש קבצי נורמליזציה של שמע.", - "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה" + "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה", + "TaskDownloadMissingLyrics": "הורדת מילים חסרות", + "TaskDownloadMissingLyricsDescription": "הורדת מילים לשירים", + "TaskMoveTrickplayImages": "מעביר את מיקום תמונות Trickplay", + "TaskExtractMediaSegments": "סריקת מדיה", + "TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.", + "TaskMoveTrickplayImagesDescription": "מזיז קבצי trickplay קיימים בהתאם להגדרות הספרייה." } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 31d6aaedb9..f205e8b64c 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -1,13 +1,13 @@ { "Albums": "Albumok", - "AppDeviceValues": "Program: {0}, Eszköz: {1}", + "AppDeviceValues": "Program: {0}, eszköz: {1}", "Application": "Alkalmazás", "Artists": "Előadók", "AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve", "Books": "Könyvek", "CameraImageUploadedFrom": "Új kamerakép lett feltöltve innen: {0}", "Channels": "Csatornák", - "ChapterNameValue": "Jelenet {0}", + "ChapterNameValue": "{0}. jelenet", "Collections": "Gyűjtemények", "DeviceOfflineWithName": "{0} kijelentkezett", "DeviceOnlineWithName": "{0} belépett", @@ -15,31 +15,31 @@ "Favorites": "Kedvencek", "Folders": "Könyvtárak", "Genres": "Műfajok", - "HeaderAlbumArtists": "Album előadók", + "HeaderAlbumArtists": "Albumelőadók", "HeaderContinueWatching": "Megtekintés folytatása", - "HeaderFavoriteAlbums": "Kedvenc Albumok", - "HeaderFavoriteArtists": "Kedvenc Előadók", - "HeaderFavoriteEpisodes": "Kedvenc Epizódok", - "HeaderFavoriteShows": "Kedvenc Sorozatok", - "HeaderFavoriteSongs": "Kedvenc Dalok", + "HeaderFavoriteAlbums": "Kedvenc albumok", + "HeaderFavoriteArtists": "Kedvenc előadók", + "HeaderFavoriteEpisodes": "Kedvenc epizódok", + "HeaderFavoriteShows": "Kedvenc sorozatok", + "HeaderFavoriteSongs": "Kedvenc számok", "HeaderLiveTV": "Élő TV", "HeaderNextUp": "Következik", - "HeaderRecordingGroups": "Felvevő Csoportok", - "HomeVideos": "Otthoni Videók", - "Inherit": "Örökölt", - "ItemAddedWithName": "{0} hozzáadva a könyvtárhoz", - "ItemRemovedWithName": "{0} eltávolítva a könyvtárból", + "HeaderRecordingGroups": "Felvételi csoportok", + "HomeVideos": "Otthoni videók", + "Inherit": "Öröklés", + "ItemAddedWithName": "{0} hozzáadva a médiatárhoz", + "ItemRemovedWithName": "{0} eltávolítva a médiatárból", "LabelIpAddressValue": "IP-cím: {0}", "LabelRunningTimeValue": "Lejátszási idő: {0}", "Latest": "Legújabb", "MessageApplicationUpdated": "A Jellyfin kiszolgáló frissítve lett", "MessageApplicationUpdatedTo": "A Jellyfin kiszolgáló frissítve lett a következőre: {0}", "MessageNamedServerConfigurationUpdatedWithValue": "A kiszolgálókonfigurációs rész frissítve lett: {0}", - "MessageServerConfigurationUpdated": "Kiszolgálókonfiguráció frissítve lett", + "MessageServerConfigurationUpdated": "A kiszolgálókonfiguráció frissítve lett", "MixedContent": "Vegyes tartalom", "Movies": "Filmek", "Music": "Zenék", - "MusicVideos": "Zenei videóklippek", + "MusicVideos": "Zenei videóklipek", "NameInstallFailed": "{0} sikertelen telepítés", "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", @@ -56,7 +56,7 @@ "NotificationOptionPluginUninstalled": "Bővítmény eltávolítva", "NotificationOptionPluginUpdateInstalled": "Bővítményfrissítés telepítve", "NotificationOptionServerRestartRequired": "A kiszolgáló újraindítása szükséges", - "NotificationOptionTaskFailed": "Ütemezett feladat hiba", + "NotificationOptionTaskFailed": "Hiba az ütemezett feladatban", "NotificationOptionUserLockedOut": "Felhasználó tiltva", "NotificationOptionVideoPlayback": "Videólejátszás elkezdve", "NotificationOptionVideoPlaybackStopped": "Videólejátszás leállítva", @@ -107,7 +107,7 @@ "TaskCleanCache": "Gyorsítótár könyvtárának ürítése", "TasksChannelsCategory": "Internetes csatornák", "TasksApplicationCategory": "Alkalmazás", - "TasksLibraryCategory": "Könyvtár", + "TasksLibraryCategory": "Médiatár", "TasksMaintenanceCategory": "Karbantartás", "TaskDownloadMissingSubtitlesDescription": "A metaadat-konfiguráció alapján ellenőrzi és letölti a hiányzó feliratokat az internetről.", "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése", @@ -119,16 +119,22 @@ "Undefined": "Meghatározatlan", "Forced": "Kényszerített", "Default": "Alapértelmezett", - "TaskOptimizeDatabaseDescription": "Tömöríti az adatbázist és csonkolja a szabad helyet. A feladat futtatása a könyvtár beolvasása után, vagy egyéb, adatbázis-módosítást igénylő változtatások végrehajtása javíthatja a teljesítményt.", + "TaskOptimizeDatabaseDescription": "Tömöríti az adatbázist és csonkolja a szabad helyet. A feladat futtatása a médiatár beolvasása, vagy egyéb adatbázis-módosítást igénylő változtatás végrehajtása után, javíthatja a teljesítményt.", "TaskOptimizeDatabase": "Adatbázis optimalizálása", "TaskKeyframeExtractor": "Kulcsképkockák kibontása", "TaskKeyframeExtractorDescription": "Kibontja a kulcsképkockákat a videófájlokból, hogy pontosabb HLS lejátszási listákat hozzon létre. Ez a feladat hosszú ideig tarthat.", "External": "Külső", "HearingImpaired": "Hallássérült", - "TaskRefreshTrickplayImages": "Trickplay képek generálása", + "TaskRefreshTrickplayImages": "Trickplay képek előállítása", "TaskRefreshTrickplayImagesDescription": "Trickplay előnézetet készít az engedélyezett könyvtárakban lévő videókhoz.", - "TaskAudioNormalization": "Hangerő Normalizáció", + "TaskAudioNormalization": "Hangerő-normalizálás", "TaskCleanCollectionsAndPlaylistsDescription": "Nem létező elemek törlése a gyűjteményekből és lejátszási listákról.", - "TaskAudioNormalizationDescription": "Hangerő normalizációs adatok keresése.", - "TaskCleanCollectionsAndPlaylists": "Gyűjtemények és lejátszási listák optimalizálása" + "TaskAudioNormalizationDescription": "Hangerő-normalizálási adatok keresése.", + "TaskCleanCollectionsAndPlaylists": "Gyűjtemények és lejátszási listák optimalizálása", + "TaskExtractMediaSegments": "Médiaszegmens felismerése", + "TaskDownloadMissingLyrics": "Hiányzó szöveg letöltése", + "TaskDownloadMissingLyricsDescription": "Zenék szövegének letöltése", + "TaskMoveTrickplayImages": "Trickplay képek helyének átköltöztetése", + "TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.", + "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből." } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 961d1a0df7..6b0cfb3594 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -132,5 +132,7 @@ "TaskAudioNormalization": "Normalizzazione dell'audio", "TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio.", "TaskDownloadMissingLyricsDescription": "Scarica testi per le canzoni", - "TaskDownloadMissingLyrics": "Scarica testi mancanti" + "TaskDownloadMissingLyrics": "Scarica testi mancanti", + "TaskMoveTrickplayImages": "Sposta le immagini Trickplay", + "TaskMoveTrickplayImagesDescription": "Sposta le immagini Trickplay esistenti secondo la configurazione della libreria." } diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index c8ed7d0fbc..10f4aee251 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -129,5 +129,10 @@ "TaskCleanCollectionsAndPlaylists": "コレクションとプレイリストをクリーンアップ", "TaskAudioNormalization": "音声の正規化", "TaskAudioNormalizationDescription": "音声の正規化データのためにファイルをスキャンします。", - "TaskCleanCollectionsAndPlaylistsDescription": "在しなくなったコレクションやプレイリストからアイテムを削除します。" + "TaskCleanCollectionsAndPlaylistsDescription": "在しなくなったコレクションやプレイリストからアイテムを削除します。", + "TaskDownloadMissingLyricsDescription": "歌詞をダウンロード", + "TaskExtractMediaSegments": "メディアセグメントを読み取る", + "TaskMoveTrickplayImages": "Trickplayの画像を移動", + "TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。", + "TaskDownloadMissingLyrics": "記録されていない歌詞をダウンロード" } diff --git a/Emby.Server.Implementations/Localization/Core/kw.json b/Emby.Server.Implementations/Localization/Core/kw.json index ffb4345c89..336d286fc4 100644 --- a/Emby.Server.Implementations/Localization/Core/kw.json +++ b/Emby.Server.Implementations/Localization/Core/kw.json @@ -131,5 +131,9 @@ "TaskCleanCollectionsAndPlaylists": "Glanhe kuntellow ha rolyow-gwari", "TaskKeyframeExtractor": "Estennell Framalhwedh", "TaskCleanCollectionsAndPlaylistsDescription": "Y hwra dilea taklow a-dhyworth kuntellow ha rolyow-gwari na vos na moy.", - "TaskKeyframeExtractorDescription": "Y hwra kuntel framyowalhwedh a-dhyworth restrennow gwydhyowyow rag gul rolyow-gwari HLS moy poran. Martesen y hwra an oberen ma ow ponya rag termyn hir." + "TaskKeyframeExtractorDescription": "Y hwra kuntel framyowalhwedh a-dhyworth restrennow gwydhyowyow rag gul rolyow-gwari HLS moy poran. Martesen y hwra an oberen ma ow ponya rag termyn hir.", + "TaskExtractMediaSegments": "Arhwilas Rann Media", + "TaskExtractMediaSegmentsDescription": "Kavos rannow media a-dhyworth ystynansow gallosegys MediaSegment.", + "TaskMoveTrickplayImages": "Divroa Tyller Imach TrickPlay", + "TaskMoveTrickplayImagesDescription": "Y hwra movya restrennow a-lemmyn trickplay herwydh settyansow lyverva." } diff --git a/Emby.Server.Implementations/Localization/Core/mk.json b/Emby.Server.Implementations/Localization/Core/mk.json index 7ef9079188..e149f8adfd 100644 --- a/Emby.Server.Implementations/Localization/Core/mk.json +++ b/Emby.Server.Implementations/Localization/Core/mk.json @@ -55,7 +55,7 @@ "Genres": "Жанрови", "Folders": "Папки", "Favorites": "Омилени", - "FailedLoginAttemptWithUserName": "Неуспешно поврзување од {0}", + "FailedLoginAttemptWithUserName": "Неуспешен обид за најавување од {0}", "DeviceOnlineWithName": "{0} е приклучен", "DeviceOfflineWithName": "{0} се исклучи", "Collections": "Колекции", @@ -123,5 +123,13 @@ "TaskCleanActivityLogDescription": "Избришува логови на активности постари од определеното време.", "TaskCleanActivityLog": "Избриши Лог на Активности", "External": "Надворешен", - "HearingImpaired": "Оштетен слух" + "HearingImpaired": "Оштетен слух", + "TaskCleanCollectionsAndPlaylists": "Исчисти ги колекциите и плејлистите", + "TaskAudioNormalizationDescription": "Скенирање датотеки за податоци за нормализација на звукот.", + "TaskDownloadMissingLyrics": "Преземи стихови кои недостасуваат", + "TaskDownloadMissingLyricsDescription": "Преземи стихови/текстови за песни", + "TaskRefreshTrickplayImages": "Генерирај слики за прегледување (Trickplay)", + "TaskAudioNormalization": "Нормализација на звукот", + "TaskRefreshTrickplayImagesDescription": "Креира трикплеј прегледи за видеа во овозможените библиотеки.", + "TaskCleanCollectionsAndPlaylistsDescription": "Отстранува ставки од колекциите и плејлистите што веќе не постојат." } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 7476525387..b1b6e96ea1 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -128,9 +128,13 @@ "TaskRefreshTrickplayImages": "Generer Trickplay bilder", "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker.", "TaskCleanCollectionsAndPlaylists": "Rydd kolleksjoner og spillelister", - "TaskAudioNormalization": "Lyd Normalisering", - "TaskAudioNormalizationDescription": "Skan filer for lyd normaliserende data", - "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes", + "TaskAudioNormalization": "Lydnormalisering", + "TaskAudioNormalizationDescription": "Skan filer for lydnormaliserende data.", + "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes.", "TaskDownloadMissingLyrics": "Last ned manglende tekster", - "TaskDownloadMissingLyricsDescription": "Last ned sangtekster" + "TaskDownloadMissingLyricsDescription": "Last ned sangtekster", + "TaskExtractMediaSegments": "Skann mediasegment", + "TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay", + "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til bibliotekseinstillingene.", + "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment." } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 39e7cd546d..7d101195b7 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -16,13 +16,13 @@ "Folders": "Mappen", "Genres": "Genres", "HeaderAlbumArtists": "Albumartiesten", - "HeaderContinueWatching": "Kijken hervatten", + "HeaderContinueWatching": "Verderkijken", "HeaderFavoriteAlbums": "Favoriete albums", "HeaderFavoriteArtists": "Favoriete artiesten", "HeaderFavoriteEpisodes": "Favoriete afleveringen", - "HeaderFavoriteShows": "Favoriete shows", + "HeaderFavoriteShows": "Favoriete series", "HeaderFavoriteSongs": "Favoriete nummers", - "HeaderLiveTV": "Live TV", + "HeaderLiveTV": "Live-tv", "HeaderNextUp": "Volgende", "HeaderRecordingGroups": "Opnamegroepen", "HomeVideos": "Homevideo's", @@ -34,8 +34,8 @@ "Latest": "Nieuwste", "MessageApplicationUpdated": "Jellyfin Server is bijgewerkt", "MessageApplicationUpdatedTo": "Jellyfin Server is bijgewerkt naar {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Sectie {0} van de server configuratie is bijgewerkt", - "MessageServerConfigurationUpdated": "Server configuratie is bijgewerkt", + "MessageNamedServerConfigurationUpdatedWithValue": "Sectie {0} van de serverconfiguratie is bijgewerkt", + "MessageServerConfigurationUpdated": "Serverconfiguratie is bijgewerkt", "MixedContent": "Gemengde inhoud", "Movies": "Films", "Music": "Muziek", @@ -50,12 +50,12 @@ "NotificationOptionAudioPlaybackStopped": "Muziek gestopt", "NotificationOptionCameraImageUploaded": "Camera-afbeelding geüpload", "NotificationOptionInstallationFailed": "Installatie mislukt", - "NotificationOptionNewLibraryContent": "Nieuwe content toegevoegd", - "NotificationOptionPluginError": "Plug-in fout", + "NotificationOptionNewLibraryContent": "Nieuwe inhoud toegevoegd", + "NotificationOptionPluginError": "Plug-in-fout", "NotificationOptionPluginInstalled": "Plug-in geïnstalleerd", "NotificationOptionPluginUninstalled": "Plug-in verwijderd", "NotificationOptionPluginUpdateInstalled": "Plug-in-update geïnstalleerd", - "NotificationOptionServerRestartRequired": "Server herstart nodig", + "NotificationOptionServerRestartRequired": "Herstarten server vereist", "NotificationOptionTaskFailed": "Geplande taak mislukt", "NotificationOptionUserLockedOut": "Gebruiker is vergrendeld", "NotificationOptionVideoPlayback": "Afspelen van video gestart", @@ -72,16 +72,16 @@ "ServerNameNeedsToBeRestarted": "{0} moet herstart worden", "Shows": "Series", "Songs": "Nummers", - "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden, probeer het later opnieuw.", + "StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden. Probeer het later opnieuw.", "SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt", - "SubtitleDownloadFailureFromForItem": "Ondertitels konden niet gedownload worden van {0} voor {1}", + "SubtitleDownloadFailureFromForItem": "Ondertiteling kon niet gedownload worden van {0} voor {1}", "Sync": "Synchronisatie", "System": "Systeem", "TvShows": "TV-series", "User": "Gebruiker", "UserCreatedWithName": "Gebruiker {0} is aangemaakt", "UserDeletedWithName": "Gebruiker {0} is verwijderd", - "UserDownloadingItemWithValues": "{0} download {1}", + "UserDownloadingItemWithValues": "{0} downloadt {1}", "UserLockedOutWithName": "Gebruikersaccount {0} is vergrendeld", "UserOfflineFromDevice": "Verbinding van {0} met {1} is verbroken", "UserOnlineFromDevice": "{0} heeft verbinding met {1}", @@ -90,7 +90,7 @@ "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}", "UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}", "ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek", - "ValueSpecialEpisodeName": "Speciaal - {0}", + "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Versie {0}", "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar ontbrekende ondertiteling gebaseerd op metadataconfiguratie.", "TaskDownloadMissingSubtitles": "Ontbrekende ondertiteling downloaden", @@ -132,5 +132,9 @@ "TaskAudioNormalization": "Geluidsnormalisatie", "TaskAudioNormalizationDescription": "Scant bestanden op gegevens voor geluidsnormalisatie.", "TaskDownloadMissingLyrics": "Ontbrekende liedteksten downloaden", - "TaskDownloadMissingLyricsDescription": "Downloadt liedteksten" + "TaskDownloadMissingLyricsDescription": "Downloadt liedteksten", + "TaskExtractMediaSegmentsDescription": "Verkrijgt mediasegmenten vanuit plug-ins met MediaSegment-ondersteuning.", + "TaskMoveTrickplayImages": "Locatie trickplay-afbeeldingen migreren", + "TaskMoveTrickplayImagesDescription": "Verplaatst bestaande trickplay-bestanden op basis van de bibliotheekinstellingen.", + "TaskExtractMediaSegments": "Scannen op mediasegmenten" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index a24a837aba..33b0bb7e15 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -132,5 +132,9 @@ "TaskAudioNormalization": "Normalizacja dźwięku", "TaskAudioNormalizationDescription": "Skanuje pliki w poszukiwaniu danych normalizacji dźwięku.", "TaskDownloadMissingLyrics": "Pobierz brakujące słowa", - "TaskDownloadMissingLyricsDescription": "Pobierz słowa piosenek" + "TaskDownloadMissingLyricsDescription": "Pobierz słowa piosenek", + "TaskExtractMediaSegments": "Skanowanie segmentów mediów", + "TaskMoveTrickplayImages": "Migruj lokalizację obrazu Trickplay", + "TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.", + "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki." } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index d9867f5e05..9f4f58cb69 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "Uma nova imagem da câmera foi enviada de {0}", "Channels": "Canais", "ChapterNameValue": "Capítulo {0}", - "Collections": "Coletâneas", + "Collections": "Coleções", "DeviceOfflineWithName": "{0} se desconectou", "DeviceOnlineWithName": "{0} se conectou", "FailedLoginAttemptWithUserName": "Falha na tentativa de login de {0}", @@ -130,5 +130,11 @@ "TaskCleanCollectionsAndPlaylists": "Limpe coleções e playlists", "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e playlists que não existem mais.", "TaskAudioNormalization": "Normalização de áudio", - "TaskAudioNormalizationDescription": "Examina os ficheiros em busca de dados de normalização de áudio." + "TaskAudioNormalizationDescription": "Examina os ficheiros em busca de dados de normalização de áudio.", + "TaskDownloadMissingLyricsDescription": "Baixar letras para músicas", + "TaskDownloadMissingLyrics": "Baixar letra faltante", + "TaskMoveTrickplayImagesDescription": "Move os arquivos do trickplay de acordo com as configurações da biblioteca.", + "TaskExtractMediaSegments": "Varredura do segmento de mídia", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de mídia de plug-ins habilitados para MediaSegment.", + "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index ff9a0d4f42..7e9be76e51 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -129,5 +129,11 @@ "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.", "TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução", "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.", - "TaskAudioNormalization": "Normalização de áudio" + "TaskAudioNormalization": "Normalização de áudio", + "TaskDownloadMissingLyrics": "Baixar letras faltantes", + "TaskDownloadMissingLyricsDescription": "Baixa letras para músicas", + "TaskMoveTrickplayImagesDescription": "Transfere ficheiros de miniatura de vídeo, conforme as definições da biblioteca.", + "TaskExtractMediaSegments": "Varrimento de segmentos da média", + "TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de média de extensões com suporte a MediaSegment.", + "TaskMoveTrickplayImages": "Migração de miniaturas de vídeo" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index 2f52aafa36..bf59e15837 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -129,5 +129,11 @@ "TaskAudioNormalizationDescription": "Scanează fișiere pentru date necesare normalizării sunetului.", "TaskAudioNormalization": "Normalizare sunet", "TaskCleanCollectionsAndPlaylists": "Curăță colecțiile și listele de redare", - "TaskCleanCollectionsAndPlaylistsDescription": "Elimină elementele care nu mai există din colecții și liste de redare." + "TaskCleanCollectionsAndPlaylistsDescription": "Elimină elementele care nu mai există din colecții și liste de redare.", + "TaskExtractMediaSegments": "Scanează segmentele media", + "TaskMoveTrickplayImagesDescription": "Mută fișierele trickplay existente conform setărilor librăriei.", + "TaskExtractMediaSegmentsDescription": "Extrage sau obține segmentele media de la pluginurile MediaSegment activate.", + "TaskMoveTrickplayImages": "Migrează locația imaginii Trickplay", + "TaskDownloadMissingLyrics": "Descarcă versurile lipsă", + "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index a9b6fbeef4..66d8bf899b 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -130,5 +130,11 @@ "TaskCleanCollectionsAndPlaylists": "Vyčistiť kolekcie a playlisty", "TaskCleanCollectionsAndPlaylistsDescription": "Odstráni položky z kolekcií a playlistov, ktoré už neexistujú.", "TaskAudioNormalization": "Normalizácia zvuku", - "TaskAudioNormalizationDescription": "Skenovať súbory za účelom normalizácie zvuku." + "TaskAudioNormalizationDescription": "Skenovať súbory za účelom normalizácie zvuku.", + "TaskExtractMediaSegments": "Skenovanie segmentov médií", + "TaskExtractMediaSegmentsDescription": "Extrahuje alebo získava segmenty médií zo zásuvných modulov s povolenou funkciou MediaSegment.", + "TaskMoveTrickplayImages": "Presunúť umiestnenie obrázkov Trickplay", + "TaskMoveTrickplayImagesDescription": "Presunie existujúce súbory Trickplay podľa nastavení knižnice.", + "TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní", + "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 110af11b71..19be1a23e0 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -3,7 +3,7 @@ "AppDeviceValues": "Aplikacija: {0}, Naprava: {1}", "Application": "Aplikacija", "Artists": "Izvajalci", - "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil", + "AuthenticationSucceededWithUserName": "{0} se je uspešno prijavil/a", "Books": "Knjige", "CameraImageUploadedFrom": "Nova fotografija je bila naložena iz {0}", "Channels": "Kanali", diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index a4e2302d16..5cf54522bf 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -9,7 +9,7 @@ "Channels": "Kanaler", "ChapterNameValue": "Kapitel {0}", "Collections": "Samlingar", - "DeviceOfflineWithName": "{0} har avbrutit uppkopplingen", + "DeviceOfflineWithName": "{0} har kopplat ned", "DeviceOnlineWithName": "{0} är ansluten", "FailedLoginAttemptWithUserName": "Misslyckat inloggningsförsök från {0}", "Favorites": "Favoriter", @@ -121,7 +121,7 @@ "Default": "Standard", "TaskOptimizeDatabase": "Optimera databasen", "TaskOptimizeDatabaseDescription": "Komprimerar databasen och trunkerar ledigt utrymme. Prestandan kan förbättras genom att köra denna aktivitet efter att du har skannat biblioteket eller gjort andra förändringar som indikerar att databasen har modifierats.", - "TaskKeyframeExtractorDescription": "Exporterar nyckelbildrutor från videofiler för att skapa mer exakta HLS-spellistor. Denna rutin kan ta lång tid.", + "TaskKeyframeExtractorDescription": "Exporterar nyckelbildrutor från videofiler för att skapa mer exakta HLS-spellistor. Denna körning kan ta lång tid.", "TaskKeyframeExtractor": "Extraktor för nyckelbildrutor", "External": "Extern", "HearingImpaired": "Hörselskadad", @@ -132,5 +132,9 @@ "TaskCleanCollectionsAndPlaylistsDescription": "Tar bort objekt från samlingar och spellistor som inte längre finns.", "TaskAudioNormalizationDescription": "Skannar filer för ljudnormaliseringsdata.", "TaskDownloadMissingLyrics": "Ladda ner saknad låttext", - "TaskDownloadMissingLyricsDescription": "Laddar ner låttexter" + "TaskDownloadMissingLyricsDescription": "Laddar ner låttexter", + "TaskExtractMediaSegments": "Skanning av mediesegment", + "TaskExtractMediaSegmentsDescription": "Extraherar eller hämtar ut mediesegmen från tillägg som stöder MediaSegment.", + "TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder", + "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar." } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 1dceadc611..a3cf78fcb2 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -130,5 +130,11 @@ "TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.", "TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin", "TaskAudioNormalizationDescription": "Ses normalleştirme verileri için dosyaları tarar.", - "TaskAudioNormalization": "Ses Normalleştirme" + "TaskAudioNormalization": "Ses Normalleştirme", + "TaskExtractMediaSegments": "Medya Segmenti Tarama", + "TaskMoveTrickplayImages": "Trickplay Görsel Konumunu Taşıma", + "TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.", + "TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir", + "TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir", + "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır." } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 97bad45323..3fddc2e780 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -131,5 +131,9 @@ "TaskAudioNormalizationDescription": "Сканує файли на наявність даних для нормалізації звуку.", "TaskAudioNormalization": "Нормалізація аудіо", "TaskDownloadMissingLyrics": "Завантажити відсутні тексти пісень", - "TaskDownloadMissingLyricsDescription": "Завантаження текстів пісень" + "TaskDownloadMissingLyricsDescription": "Завантаження текстів пісень", + "TaskMoveTrickplayImagesDescription": "Переміщує наявні Trickplay-зображення відповідно до налаштувань медіатеки.", + "TaskExtractMediaSegments": "Сканування медіа-сегментів", + "TaskMoveTrickplayImages": "Змінити місце розташування Trickplay-зображень", + "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment." } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 32e2f4bab1..f890ea74dc 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -131,5 +131,9 @@ "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh", "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.", "TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát", - "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu" + "TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu", + "TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.", + "TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay", + "TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.", + "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 4bec590fbe..9a0e2115ed 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -93,7 +93,7 @@ "ValueSpecialEpisodeName": "特典 - {0}", "VersionNumber": "版本 {0}", "TaskUpdatePluginsDescription": "为已设置为自动更新的插件下载和安装更新。", - "TaskRefreshPeople": "刷新人员", + "TaskRefreshPeople": "刷新演职人员", "TasksChannelsCategory": "互联网频道", "TasksLibraryCategory": "媒体库", "TaskDownloadMissingSubtitlesDescription": "根据元数据设置在互联网上搜索缺少的字幕。", @@ -122,15 +122,19 @@ "TaskOptimizeDatabaseDescription": "压缩数据库并优化可用空间,在扫描库或执行其他数据库修改后运行此任务可能会提高性能。", "TaskOptimizeDatabase": "优化数据库", "TaskKeyframeExtractorDescription": "从视频文件中提取关键帧以创建更准确的 HLS 播放列表。这项任务可能需要很长时间。", - "TaskKeyframeExtractor": "关键帧提取器", + "TaskKeyframeExtractor": "关键帧提取", "External": "外部", "HearingImpaired": "听力障碍", - "TaskRefreshTrickplayImages": "生成时间轴缩略图", - "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成时间轴缩略图。", + "TaskRefreshTrickplayImages": "生成进度条预览图", + "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成进度条预览图。", "TaskCleanCollectionsAndPlaylists": "清理合集和播放列表", "TaskCleanCollectionsAndPlaylistsDescription": "清理合集和播放列表中已不存在的项目。", "TaskAudioNormalization": "音频标准化", "TaskAudioNormalizationDescription": "扫描文件以寻找音频标准化数据。", "TaskDownloadMissingLyrics": "下载缺失的歌词", - "TaskDownloadMissingLyricsDescription": "下载歌曲歌词" + "TaskDownloadMissingLyricsDescription": "下载歌曲歌词", + "TaskMoveTrickplayImages": "迁移进度条预览图的存储位置", + "TaskExtractMediaSegments": "媒体片段扫描", + "TaskExtractMediaSegmentsDescription": "从支持 MediaSegment 的插件中提取或获取媒体片段。", + "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index f06bbc5912..81d5b83d61 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -129,5 +129,11 @@ "TaskCleanCollectionsAndPlaylists": "清理系列和播放清單", "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。", "TaskAudioNormalization": "音量標準化", - "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。" + "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。", + "TaskDownloadMissingLyrics": "下載缺少的歌詞", + "TaskDownloadMissingLyricsDescription": "卡在歌曲歌詞", + "TaskExtractMediaSegments": "掃描媒體片段", + "TaskExtractMediaSegmentsDescription": "從使用媒體片段的擴充功能取得媒體片段。", + "TaskMoveTrickplayImages": "遷移快轉縮圖位置", + "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。" } diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index 896f47923f..eb55e32c50 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -91,8 +91,29 @@ namespace Emby.Server.Implementations.MediaEncoder return video.DefaultVideoStreamIndex.HasValue; } + private long GetAverageDurationBetweenChapters(IReadOnlyList chapters) + { + if (chapters.Count < 2) + { + return 0; + } + + long sum = 0; + for (int i = 1; i < chapters.Count; i++) + { + sum += chapters[i].StartPositionTicks - chapters[i - 1].StartPositionTicks; + } + + return sum / chapters.Count; + } + public async Task RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken) { + if (chapters.Count == 0) + { + return true; + } + var libraryOptions = _libraryManager.GetLibraryOptions(video); if (!IsEligibleForChapterImageExtraction(video, libraryOptions)) @@ -100,6 +121,14 @@ namespace Emby.Server.Implementations.MediaEncoder extractImages = false; } + var averageChapterDuration = GetAverageDurationBetweenChapters(chapters); + var threshold = TimeSpan.FromSeconds(1).Ticks; + if (averageChapterDuration < threshold) + { + _logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold); + extractImages = false; + } + var success = true; var changesMade = false; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs new file mode 100644 index 0000000000..d6fad7526b --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks; + +/// +/// Task to obtain media segments. +/// +public class MediaSegmentExtractionTask : IScheduledTask +{ + /// + /// The library manager. + /// + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; + private readonly IMediaSegmentManager _mediaSegmentManager; + private static readonly BaseItemKind[] _itemTypes = [BaseItemKind.Episode, BaseItemKind.Movie, BaseItemKind.Audio, BaseItemKind.AudioBook]; + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + /// The localization manager. + /// The segment manager. + public MediaSegmentExtractionTask(ILibraryManager libraryManager, ILocalizationManager localization, IMediaSegmentManager mediaSegmentManager) + { + _libraryManager = libraryManager; + _localization = localization; + _mediaSegmentManager = mediaSegmentManager; + } + + /// + public string Name => _localization.GetLocalizedString("TaskExtractMediaSegments"); + + /// + public string Description => _localization.GetLocalizedString("TaskExtractMediaSegmentsDescription"); + + /// + public string Category => _localization.GetLocalizedString("TasksLibraryCategory"); + + /// + public string Key => "TaskExtractMediaSegments"; + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + progress.Report(0); + + var pagesize = 100; + + var query = new InternalItemsQuery + { + MediaTypes = new[] { MediaType.Video, MediaType.Audio }, + IsVirtualItem = false, + IncludeItemTypes = _itemTypes, + DtoOptions = new DtoOptions(true), + SourceTypes = new[] { SourceType.Library }, + Recursive = true, + Limit = pagesize + }; + + var numberOfVideos = _libraryManager.GetCount(query); + + var startIndex = 0; + var numComplete = 0; + + while (startIndex < numberOfVideos) + { + query.StartIndex = startIndex; + + var baseItems = _libraryManager.GetItemList(query); + var currentPageCount = baseItems.Count; + // TODO parallelize with Parallel.ForEach? + for (var i = 0; i < currentPageCount; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var item = baseItems[i]; + // Only local files supported + if (item.IsFileProtocol && File.Exists(item.Path)) + { + await _mediaSegmentManager.RunSegmentPluginProviders(item, false, cancellationToken).ConfigureAwait(false); + } + + // Update progress + numComplete++; + double percent = (double)numComplete / numberOfVideos; + progress.Report(100 * percent); + } + + startIndex += pagesize; + } + + progress.Report(100); + } + + /// + public IEnumerable GetDefaultTriggers() + { + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(12).Ticks + }; + } +} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 72e164b521..6a8ad2bdc5 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -68,13 +66,29 @@ namespace Emby.Server.Implementations.Session private Timer _inactiveTimer; private DtoOptions _itemInfoDtoOptions; - private bool _disposed = false; + private bool _disposed; + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. public SessionManager( ILogger logger, IEventManager eventManager, IUserDataManager userDataManager, - IServerConfigurationManager config, + IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, IUserManager userManager, IMusicManager musicManager, @@ -88,7 +102,7 @@ namespace Emby.Server.Implementations.Session _logger = logger; _eventManager = eventManager; _userDataManager = userDataManager; - _config = config; + _config = serverConfigurationManager; _libraryManager = libraryManager; _userManager = userManager; _musicManager = musicManager; @@ -508,7 +522,10 @@ namespace Emby.Server.Implementations.Session deviceName = "Network Device"; } - var deviceOptions = _deviceManager.GetDeviceOptions(deviceId); + var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new() + { + DeviceId = deviceId + }; if (string.IsNullOrEmpty(deviceOptions.CustomName)) { sessionInfo.DeviceName = deviceName; @@ -1076,6 +1093,42 @@ namespace Emby.Server.Implementations.Session return session; } + private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo) + { + return new SessionInfoDto + { + PlayState = sessionInfo.PlayState, + AdditionalUsers = sessionInfo.AdditionalUsers, + Capabilities = _deviceManager.ToClientCapabilitiesDto(sessionInfo.Capabilities), + RemoteEndPoint = sessionInfo.RemoteEndPoint, + PlayableMediaTypes = sessionInfo.PlayableMediaTypes, + Id = sessionInfo.Id, + UserId = sessionInfo.UserId, + UserName = sessionInfo.UserName, + Client = sessionInfo.Client, + LastActivityDate = sessionInfo.LastActivityDate, + LastPlaybackCheckIn = sessionInfo.LastPlaybackCheckIn, + LastPausedDate = sessionInfo.LastPausedDate, + DeviceName = sessionInfo.DeviceName, + DeviceType = sessionInfo.DeviceType, + NowPlayingItem = sessionInfo.NowPlayingItem, + NowViewingItem = sessionInfo.NowViewingItem, + DeviceId = sessionInfo.DeviceId, + ApplicationVersion = sessionInfo.ApplicationVersion, + TranscodingInfo = sessionInfo.TranscodingInfo, + IsActive = sessionInfo.IsActive, + SupportsMediaControl = sessionInfo.SupportsMediaControl, + SupportsRemoteControl = sessionInfo.SupportsRemoteControl, + NowPlayingQueue = sessionInfo.NowPlayingQueue, + NowPlayingQueueFullItems = sessionInfo.NowPlayingQueueFullItems, + HasCustomDeviceName = sessionInfo.HasCustomDeviceName, + PlaylistItemId = sessionInfo.PlaylistItemId, + ServerId = sessionInfo.ServerId, + UserPrimaryImageTag = sessionInfo.UserPrimaryImageTag, + SupportedCommands = sessionInfo.SupportedCommands + }; + } + /// public Task SendMessageCommand(string controllingSessionId, string sessionId, MessageCommand command, CancellationToken cancellationToken) { @@ -1393,7 +1446,7 @@ namespace Emby.Server.Implementations.Session UserName = user.Username }; - session.AdditionalUsers = [..session.AdditionalUsers, newUser]; + session.AdditionalUsers = [.. session.AdditionalUsers, newUser]; } } @@ -1505,7 +1558,7 @@ namespace Emby.Server.Implementations.Session var returnResult = new AuthenticationResult { User = _userManager.GetUserDto(user, request.RemoteEndPoint), - SessionInfo = session, + SessionInfo = ToSessionInfoDto(session), AccessToken = token, ServerId = _appHost.SystemId }; @@ -1800,6 +1853,105 @@ namespace Emby.Server.Implementations.Session return await GetSessionByAuthenticationToken(items[0], deviceId, remoteEndpoint, null).ConfigureAwait(false); } + /// + public IReadOnlyList GetSessions( + Guid userId, + string deviceId, + int? activeWithinSeconds, + Guid? controllableUserToCheck, + bool isApiKey) + { + var result = Sessions; + if (!string.IsNullOrEmpty(deviceId)) + { + result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); + } + + var userCanControlOthers = false; + var userIsAdmin = false; + User user = null; + + if (isApiKey) + { + userCanControlOthers = true; + userIsAdmin = true; + } + else if (!userId.IsEmpty()) + { + user = _userManager.GetUserById(userId); + if (user is not null) + { + userCanControlOthers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers); + userIsAdmin = user.HasPermission(PermissionKind.IsAdministrator); + } + else + { + return []; + } + } + + if (!controllableUserToCheck.IsNullOrEmpty()) + { + result = result.Where(i => i.SupportsRemoteControl); + + var controlledUser = _userManager.GetUserById(controllableUserToCheck.Value); + if (controlledUser is null) + { + return []; + } + + if (!controlledUser.HasPermission(PermissionKind.EnableSharedDeviceControl)) + { + // Controlled user has device sharing disabled + result = result.Where(i => !i.UserId.IsEmpty()); + } + + if (!userCanControlOthers) + { + // User cannot control other user's sessions, validate user id. + result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(userId)); + } + + result = result.Where(i => + { + if (isApiKey) + { + return true; + } + + if (user is null) + { + return false; + } + + return string.IsNullOrWhiteSpace(i.DeviceId) || _deviceManager.CanAccessDevice(user, i.DeviceId); + }); + } + else if (!userIsAdmin) + { + // Request isn't from administrator, limit to "own" sessions. + result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(userId)); + } + + if (!userIsAdmin) + { + // Don't report acceleration type for non-admin users. + result = result.Select(r => + { + r.TranscodingInfo.HardwareAccelerationType = HardwareAccelerationType.none; + return r; + }); + } + + if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) + { + var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); + result = result.Where(i => i.LastActivityDate >= minActiveDate); + } + + return result.Select(ToSessionInfoDto).ToList(); + } + /// public Task SendMessageToAdminSessions(SessionMessageType name, T data, CancellationToken cancellationToken) { diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 2a2ab4ad16..50050262f0 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -1,15 +1,13 @@ using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Data.Dtos; -using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Queries; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -47,7 +45,7 @@ public class DevicesController : BaseJellyfinApiController /// An containing the list of devices. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetDevices([FromQuery] Guid? userId) + public ActionResult> GetDevices([FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); return _deviceManager.GetDevicesForUser(userId); @@ -63,7 +61,7 @@ public class DevicesController : BaseJellyfinApiController [HttpGet("Info")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetDeviceInfo([FromQuery, Required] string id) + public ActionResult GetDeviceInfo([FromQuery, Required] string id) { var deviceInfo = _deviceManager.GetDevice(id); if (deviceInfo is null) @@ -84,7 +82,7 @@ public class DevicesController : BaseJellyfinApiController [HttpGet("Options")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetDeviceOptions([FromQuery, Required] string id) + public ActionResult GetDeviceOptions([FromQuery, Required] string id) { var deviceInfo = _deviceManager.GetDeviceOptions(id); if (deviceInfo is null) diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 662e2acbc7..54e0527c90 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -40,8 +40,8 @@ namespace Jellyfin.Api.Controllers; [Authorize] public class DynamicHlsController : BaseJellyfinApiController { - private const string DefaultVodEncoderPreset = "veryfast"; - private const string DefaultEventEncoderPreset = "superfast"; + private const EncoderPreset DefaultVodEncoderPreset = EncoderPreset.veryfast; + private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast; private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0); @@ -158,6 +158,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// Optional. The max height. /// Optional. Whether to enable subtitles in the manifest. /// Optional. Whether to enable Audio Encoding. + /// Whether to always burn in subtitles when transcoding. /// Hls live stream retrieved. /// A containing the hls file. [HttpGet("Videos/{itemId}/live.m3u8")] @@ -216,7 +217,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? enableSubtitlesInManifest, - [FromQuery] bool enableAudioVbrEncoding = true) + [FromQuery] bool enableAudioVbrEncoding = true, + [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false) { VideoRequestDto streamingRequest = new VideoRequestDto { @@ -251,7 +253,7 @@ public class DynamicHlsController : BaseJellyfinApiController Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, RequireAvc = requireAvc ?? false, @@ -271,7 +273,8 @@ public class DynamicHlsController : BaseJellyfinApiController MaxHeight = maxHeight, MaxWidth = maxWidth, EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true, - EnableAudioVbrEncoding = enableAudioVbrEncoding + EnableAudioVbrEncoding = enableAudioVbrEncoding, + AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding }; // CTS lifecycle is managed internally. @@ -398,6 +401,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// Enable adaptive bitrate streaming. /// Enable trickplay image playlists being added to master playlist. /// Whether to enable Audio Encoding. + /// Whether to always burn in subtitles when transcoding. /// Video stream returned. /// A containing the playlist file. [HttpGet("Videos/{itemId}/master.m3u8")] @@ -457,7 +461,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] Dictionary streamOptions, [FromQuery] bool enableAdaptiveBitrateStreaming = true, [FromQuery] bool enableTrickplay = true, - [FromQuery] bool enableAudioVbrEncoding = true) + [FromQuery] bool enableAudioVbrEncoding = true, + [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false) { var streamingRequest = new HlsVideoRequestDto { @@ -493,7 +498,7 @@ public class DynamicHlsController : BaseJellyfinApiController MaxHeight = maxHeight, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, RequireAvc = requireAvc ?? false, @@ -512,7 +517,8 @@ public class DynamicHlsController : BaseJellyfinApiController StreamOptions = streamOptions, EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming, EnableTrickplay = enableTrickplay, - EnableAudioVbrEncoding = enableAudioVbrEncoding + EnableAudioVbrEncoding = enableAudioVbrEncoding, + AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding }; return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); @@ -663,7 +669,7 @@ public class DynamicHlsController : BaseJellyfinApiController Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, RequireAvc = requireAvc ?? false, @@ -681,7 +687,8 @@ public class DynamicHlsController : BaseJellyfinApiController Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming, - EnableAudioVbrEncoding = enableAudioVbrEncoding + EnableAudioVbrEncoding = enableAudioVbrEncoding, + AlwaysBurnInSubtitleWhenTranscoding = false }; return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); @@ -741,6 +748,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// Optional. The . /// Optional. The streaming options. /// Optional. Whether to enable Audio Encoding. + /// Whether to always burn in subtitles when transcoding. /// Video stream returned. /// A containing the audio file. [HttpGet("Videos/{itemId}/main.m3u8")] @@ -797,7 +805,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, [FromQuery] Dictionary streamOptions, - [FromQuery] bool enableAudioVbrEncoding = true) + [FromQuery] bool enableAudioVbrEncoding = true, + [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false) { using var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new VideoRequestDto @@ -834,7 +843,7 @@ public class DynamicHlsController : BaseJellyfinApiController MaxHeight = maxHeight, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, RequireAvc = requireAvc ?? false, @@ -851,7 +860,8 @@ public class DynamicHlsController : BaseJellyfinApiController VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, - EnableAudioVbrEncoding = enableAudioVbrEncoding + EnableAudioVbrEncoding = enableAudioVbrEncoding, + AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding }; return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) @@ -1001,7 +1011,7 @@ public class DynamicHlsController : BaseJellyfinApiController Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, RequireAvc = requireAvc ?? false, @@ -1018,7 +1028,8 @@ public class DynamicHlsController : BaseJellyfinApiController VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, - EnableAudioVbrEncoding = enableAudioVbrEncoding + EnableAudioVbrEncoding = enableAudioVbrEncoding, + AlwaysBurnInSubtitleWhenTranscoding = false }; return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) @@ -1084,6 +1095,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// Optional. The . /// Optional. The streaming options. /// Optional. Whether to enable Audio Encoding. + /// Whether to always burn in subtitles when transcoding. /// Video stream returned. /// A containing the audio file. [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] @@ -1146,7 +1158,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, [FromQuery] Dictionary streamOptions, - [FromQuery] bool enableAudioVbrEncoding = true) + [FromQuery] bool enableAudioVbrEncoding = true, + [FromQuery] bool alwaysBurnInSubtitleWhenTranscoding = false) { var streamingRequest = new VideoRequestDto { @@ -1185,7 +1198,7 @@ public class DynamicHlsController : BaseJellyfinApiController MaxHeight = maxHeight, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, RequireAvc = requireAvc ?? false, @@ -1202,7 +1215,8 @@ public class DynamicHlsController : BaseJellyfinApiController VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, - EnableAudioVbrEncoding = enableAudioVbrEncoding + EnableAudioVbrEncoding = enableAudioVbrEncoding, + AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding }; return await GetDynamicSegment(streamingRequest, segmentId) @@ -1365,7 +1379,7 @@ public class DynamicHlsController : BaseJellyfinApiController Height = height, VideoBitRate = videoBitRate, SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.External, MaxRefFrames = maxRefFrames, MaxVideoBitDepth = maxVideoBitDepth, RequireAvc = requireAvc ?? false, @@ -1382,7 +1396,8 @@ public class DynamicHlsController : BaseJellyfinApiController VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, - EnableAudioVbrEncoding = enableAudioVbrEncoding + EnableAudioVbrEncoding = enableAudioVbrEncoding, + AlwaysBurnInSubtitleWhenTranscoding = false }; return await GetDynamicSegment(streamingRequest, segmentId) @@ -1797,10 +1812,11 @@ public class DynamicHlsController : BaseJellyfinApiController var args = "-codec:v:0 " + codec; - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + var isActualOutputVideoCodecAv1 = string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase); + var isActualOutputVideoCodecHevc = string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase); + + if (isActualOutputVideoCodecHevc || isActualOutputVideoCodecAv1) { var requestedRange = state.GetRequestedRangeTypes(state.ActualOutputVideoCodec); var requestHasDOVI = requestedRange.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase); @@ -1814,10 +1830,17 @@ public class DynamicHlsController : BaseJellyfinApiController || (state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG && requestHasDOVIWithHLG) || (state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithSDR && requestHasDOVIWithSDR))) { - // Prefer dvh1 to dvhe - args += " -tag:v:0 dvh1 -strict -2"; + if (isActualOutputVideoCodecHevc) + { + // Prefer dvh1 to dvhe + args += " -tag:v:0 dvh1 -strict -2"; + } + else if (isActualOutputVideoCodecAv1) + { + args += " -tag:v:0 dav1 -strict -2"; + } } - else + else if (isActualOutputVideoCodecHevc) { // Prefer hvc1 to hev1 args += " -tag:v:0 hvc1"; @@ -1885,7 +1908,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (!string.IsNullOrEmpty(state.OutputVideoSync)) { - args += " -vsync " + state.OutputVideoSync; + args += EncodingHelper.GetVideoSyncOption(state.OutputVideoSync, _mediaEncoder.EncoderVersion); } args += _encodingHelper.GetOutputFFlags(state); diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index bc52be1842..f22ac0b73a 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -209,6 +209,7 @@ public class MediaInfoController : BaseJellyfinApiController enableTranscoding.Value, allowVideoStreamCopy.Value, allowAudioStreamCopy.Value, + playbackInfoDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false, Request.HttpContext.GetNormalizedRemoteIP()); } @@ -236,7 +237,8 @@ public class MediaInfoController : BaseJellyfinApiController StartTimeTicks = startTimeTicks, SubtitleStreamIndex = subtitleStreamIndex, UserId = userId ?? Guid.Empty, - OpenToken = mediaSource.OpenToken + OpenToken = mediaSource.OpenToken, + AlwaysBurnInSubtitleWhenTranscoding = playbackInfoDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false }).ConfigureAwait(false); info.MediaSources = new[] { openStreamResult.MediaSource }; @@ -261,6 +263,7 @@ public class MediaInfoController : BaseJellyfinApiController /// The open live stream dto. /// Whether to enable direct play. Default: true. /// Whether to enable direct stream. Default: true. + /// Always burn-in subtitle when transcoding. /// Media source opened. /// A containing a . [HttpPost("LiveStreams/Open")] @@ -277,7 +280,8 @@ public class MediaInfoController : BaseJellyfinApiController [FromQuery] Guid? itemId, [FromBody] OpenLiveStreamDto? openLiveStreamDto, [FromQuery] bool? enableDirectPlay, - [FromQuery] bool? enableDirectStream) + [FromQuery] bool? enableDirectStream, + [FromQuery] bool? alwaysBurnInSubtitleWhenTranscoding) { userId ??= openLiveStreamDto?.UserId; userId = RequestHelpers.GetUserId(User, userId); @@ -295,7 +299,8 @@ public class MediaInfoController : BaseJellyfinApiController DeviceProfile = openLiveStreamDto?.DeviceProfile, EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true, EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true, - DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } + DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http }, + AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding ?? openLiveStreamDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false }; return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false); } diff --git a/Jellyfin.Api/Controllers/MediaSegmentsController.cs b/Jellyfin.Api/Controllers/MediaSegmentsController.cs index e97704d48d..3dc5167a2e 100644 --- a/Jellyfin.Api/Controllers/MediaSegmentsController.cs +++ b/Jellyfin.Api/Controllers/MediaSegmentsController.cs @@ -45,7 +45,7 @@ public class MediaSegmentsController : BaseJellyfinApiController [HttpGet("{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> GetSegmentsAsync( + public async Task>> GetItemSegments( [FromRoute, Required] Guid itemId, [FromQuery] IEnumerable? includeSegmentTypes = null) { diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 63d6e1cc30..e6f23b1364 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -149,6 +149,37 @@ public class PlaylistsController : BaseJellyfinApiController return NoContent(); } + /// + /// Get a playlist. + /// + /// The playlist id. + /// The playlist. + /// Playlist not found. + /// + /// A objects. + /// + [HttpGet("{playlistId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetPlaylist( + [FromRoute, Required] Guid playlistId) + { + var userId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + return new PlaylistDto() + { + Shares = playlist.Shares, + OpenAccess = playlist.OpenAccess, + ItemIds = playlist.GetManageableItems().Select(t => t.Item2.Id).ToList() + }; + } + /// /// Get a playlist's users. /// @@ -467,32 +498,23 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - userId = RequestHelpers.GetUserId(User, userId); - var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value); + var callingUserId = userId ?? User.GetUserId(); + var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); if (playlist is null) { return NotFound("Playlist not found"); } var isPermitted = playlist.OpenAccess - || playlist.OwnerUserId.Equals(userId.Value) - || playlist.Shares.Any(s => s.UserId.Equals(userId.Value)); + || playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.UserId.Equals(callingUserId)); if (!isPermitted) { return Forbid(); } - var user = userId.IsNullOrEmpty() - ? null - : _userManager.GetUserById(userId.Value); - var item = _libraryManager.GetItemById(playlistId, user); - if (item is null) - { - return NotFound(); - } - - var items = item.GetManageableItems().ToArray(); + var items = playlist.GetManageableItems().ToArray(); var count = items.Length; if (startIndex.HasValue) { @@ -507,7 +529,7 @@ public class PlaylistsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - + var user = _userManager.GetUserById(callingUserId); var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); for (int index = 0; index < dtos.Count; index++) { diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 60de66ab00..2f9e9f091d 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -1,21 +1,17 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; -using Jellyfin.Api.Models.SessionDtos; using Jellyfin.Data.Enums; -using Jellyfin.Extensions; using MediaBrowser.Common.Api; -using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -31,22 +27,18 @@ public class SessionController : BaseJellyfinApiController { private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; - private readonly IDeviceManager _deviceManager; /// /// Initializes a new instance of the class. /// /// Instance of interface. /// Instance of interface. - /// Instance of interface. public SessionController( ISessionManager sessionManager, - IUserManager userManager, - IDeviceManager deviceManager) + IUserManager userManager) { _sessionManager = sessionManager; _userManager = userManager; - _deviceManager = deviceManager; } /// @@ -56,67 +48,22 @@ public class SessionController : BaseJellyfinApiController /// Filter by device Id. /// Optional. Filter by sessions that were active in the last n seconds. /// List of sessions returned. - /// An with the available sessions. + /// An with the available sessions. [HttpGet("Sessions")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetSessions( + public ActionResult> GetSessions( [FromQuery] Guid? controllableByUserId, [FromQuery] string? deviceId, [FromQuery] int? activeWithinSeconds) { - var result = _sessionManager.Sessions; - - if (!string.IsNullOrEmpty(deviceId)) - { - result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); - } - - if (!controllableByUserId.IsNullOrEmpty()) - { - result = result.Where(i => i.SupportsRemoteControl); - - var user = _userManager.GetUserById(controllableByUserId.Value); - if (user is null) - { - return NotFound(); - } - - if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) - { - // User cannot control other user's sessions, validate user id. - result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(RequestHelpers.GetUserId(User, controllableByUserId))); - } - - if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) - { - result = result.Where(i => !i.UserId.IsEmpty()); - } - - result = result.Where(i => - { - if (!string.IsNullOrWhiteSpace(i.DeviceId)) - { - if (!_deviceManager.CanAccessDevice(user, i.DeviceId)) - { - return false; - } - } - - return true; - }); - } - else if (!User.IsInRole(UserRoles.Administrator)) - { - // Request isn't from administrator, limit to "own" sessions. - result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(User.GetUserId())); - } - - if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) - { - var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); - result = result.Where(i => i.LastActivityDate >= minActiveDate); - } + Guid? controllableUserToCheck = controllableByUserId is null ? null : RequestHelpers.GetUserId(User, controllableByUserId); + var result = _sessionManager.GetSessions( + User.GetUserId(), + deviceId, + activeWithinSeconds, + controllableUserToCheck, + User.GetIsApiKey()); return Ok(result); } diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs index 60d49af9e3..2cf66144ce 100644 --- a/Jellyfin.Api/Controllers/TrickplayController.cs +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -80,7 +80,7 @@ public class TrickplayController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public ActionResult GetTrickplayTileImage( + public async Task GetTrickplayTileImage( [FromRoute, Required] Guid itemId, [FromRoute, Required] int width, [FromRoute, Required] int index, @@ -92,8 +92,9 @@ public class TrickplayController : BaseJellyfinApiController return NotFound(); } - var path = _trickplayManager.GetTrickplayTilePath(item, width, index); - if (System.IO.File.Exists(path)) + var saveWithMedia = _libraryManager.GetLibraryOptions(item).SaveTrickplayWithMedia; + var path = await _trickplayManager.GetTrickplayTilePathAsync(item, width, index, saveWithMedia).ConfigureAwait(false); + if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) { Response.Headers.ContentDisposition = "attachment"; return PhysicalFile(path, MediaTypeNames.Image.Jpeg); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index fe73534967..41c4886d4f 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -160,6 +160,7 @@ public class UniversalAudioController : BaseJellyfinApiController true, true, true, + false, Request.HttpContext.GetNormalizedRemoteIP()); } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index effe7b021b..8348fd937d 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -482,7 +482,7 @@ public class VideosController : BaseJellyfinApiController // Need to start ffmpeg (because media can't be returned directly) var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, "superfast"); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, EncoderPreset.superfast); return await FileStreamResponseHelpers.GetTranscodedFile( state, isHeadRequest, diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index ba92d811cf..0e620e72a9 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -304,6 +304,8 @@ public class DynamicHlsHelper AppendPlaylistCodecsField(playlistBuilder, state); + AppendPlaylistSupplementalCodecsField(playlistBuilder, state); + AppendPlaylistResolutionField(playlistBuilder, state); AppendPlaylistFramerateField(playlistBuilder, state); @@ -406,6 +408,48 @@ public class DynamicHlsHelper } } + /// + /// Appends a SUPPLEMENTAL-CODECS field containing formatted strings of + /// the active streams output Dolby Vision Videos. + /// + /// + /// + /// StringBuilder to append the field to. + /// StreamState of the current stream. + private void AppendPlaylistSupplementalCodecsField(StringBuilder builder, StreamState state) + { + // Dolby Vision currently cannot exist when transcoding + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + return; + } + + var dvProfile = state.VideoStream.DvProfile; + var dvLevel = state.VideoStream.DvLevel; + var dvRangeString = state.VideoStream.VideoRangeType switch + { + VideoRangeType.DOVIWithHDR10 => "db1p", + VideoRangeType.DOVIWithHLG => "db4h", + _ => string.Empty + }; + + if (dvProfile is null || dvLevel is null || string.IsNullOrEmpty(dvRangeString)) + { + return; + } + + var dvFourCc = string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1"; + builder.Append(",SUPPLEMENTAL-CODECS=\"") + .Append(dvFourCc) + .Append('.') + .Append(dvProfile.Value.ToString("D2", CultureInfo.InvariantCulture)) + .Append('.') + .Append(dvLevel.Value.ToString("D2", CultureInfo.InvariantCulture)) + .Append('/') + .Append(dvRangeString) + .Append('"'); + } + /// /// Appends a RESOLUTION field containing the resolution of the output stream. /// @@ -738,7 +782,7 @@ public class DynamicHlsHelper { var width = state.VideoStream.Width ?? 0; var height = state.VideoStream.Height ?? 0; - var framerate = state.VideoStream.AverageFrameRate ?? 30; + var framerate = state.VideoStream.ReferenceFrameRate ?? 30; var bitDepth = state.VideoStream.BitDepth ?? 8; return HlsCodecStringHelpers.GetVp9String( width, diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 9bda27031b..4adda0b695 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -156,6 +156,7 @@ public class MediaInfoHelper /// Enable transcoding. /// Allow video stream copy. /// Allow audio stream copy. + /// Always burn-in subtitle when transcoding. /// Requesting IP address. public void SetDeviceSpecificData( BaseItem item, @@ -175,6 +176,7 @@ public class MediaInfoHelper bool enableTranscoding, bool allowVideoStreamCopy, bool allowAudioStreamCopy, + bool alwaysBurnInSubtitleWhenTranscoding, IPAddress ipAddress) { var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); @@ -188,7 +190,8 @@ public class MediaInfoHelper Profile = profile, MaxAudioChannels = maxAudioChannels, AllowAudioStreamCopy = allowAudioStreamCopy, - AllowVideoStreamCopy = allowVideoStreamCopy + AllowVideoStreamCopy = allowVideoStreamCopy, + AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding, }; if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) @@ -290,6 +293,10 @@ public class MediaInfoHelper mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; mediaSource.TranscodingContainer = streamInfo.Container; mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; + if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) + { + mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true"; + } } else { @@ -307,6 +314,11 @@ public class MediaInfoHelper { mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; } + + if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) + { + mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true"; + } } } @@ -420,6 +432,7 @@ public class MediaInfoHelper true, true, true, + request.AlwaysBurnInSubtitleWhenTranscoding, httpContext.GetNormalizedRemoteIP()); } else diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 535ef27c3a..3a5db2f3fb 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -142,28 +142,15 @@ public static class StreamingHelpers } else { - // Enforce more restrictive transcoding profile for LiveTV due to compatability reasons - // Cap the MaxStreamingBitrate to 30Mbps, because we are unable to reliably probe source bitrate, - // which will cause the client to request extremely high bitrate that may fail the player/encoder - streamingRequest.VideoBitRate = streamingRequest.VideoBitRate > 30000000 ? 30000000 : streamingRequest.VideoBitRate; - - if (streamingRequest.SegmentContainer is not null) - { - // Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues - // Notably: Some channels won't play on FireFox and LG webOS - // Some channels from HDHomerun will experience A/V sync issues - streamingRequest.SegmentContainer = "ts"; - streamingRequest.VideoCodec = "h264"; - streamingRequest.AudioCodec = "aac"; - state.SupportedVideoCodecs = ["h264"]; - state.Request.VideoCodec = "h264"; - state.SupportedAudioCodecs = ["aac"]; - state.Request.AudioCodec = "aac"; - } - var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); mediaSource = liveStreamInfo.Item1; state.DirectStreamProvider = liveStreamInfo.Item2; + + // Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate. + if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null) + { + streamingRequest.VideoBitRate = Math.Min(streamingRequest.VideoBitRate.Value, mediaSource.FallbackMaxStreamingBitrate.Value); + } } var encodingOptions = serverConfigurationManager.GetEncodingOptions(); @@ -232,11 +219,17 @@ public static class StreamingHelpers } else { + var h264EquivalentBitrate = EncodingHelper.ScaleBitrate( + state.OutputVideoBitrate.Value, + state.ActualOutputVideoCodec, + "h264"); var resolution = ResolutionNormalizer.Normalize( state.VideoStream?.BitRate, state.OutputVideoBitrate.Value, + h264EquivalentBitrate, state.VideoRequest.MaxWidth, - state.VideoRequest.MaxHeight); + state.VideoRequest.MaxHeight, + state.TargetFramerate); state.VideoRequest.MaxWidth = resolution.MaxWidth; state.VideoRequest.MaxHeight = resolution.MaxHeight; diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs index 53104988f5..978e99b35c 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -65,6 +65,11 @@ public class OpenLiveStreamDto /// public bool? EnableDirectStream { get; set; } + /// + /// Gets or sets a value indicating whether always burn in subtitles when transcoding. + /// + public bool? AlwaysBurnInSubtitleWhenTranscoding { get; set; } + /// /// Gets or sets the device profile. /// diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs index 9e12ddde65..82f603ca1e 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs @@ -82,4 +82,9 @@ public class PlaybackInfoDto /// Gets or sets a value indicating whether to auto open the live stream. /// public bool? AutoOpenLiveStream { get; set; } + + /// + /// Gets or sets a value indicating whether always burn in subtitles when transcoding. + /// + public bool? AlwaysBurnInSubtitleWhenTranscoding { get; set; } } diff --git a/Jellyfin.Data/Dtos/DeviceOptionsDto.cs b/Jellyfin.Data/Dtos/DeviceOptionsDto.cs index 392ef5ff4e..aad5787097 100644 --- a/Jellyfin.Data/Dtos/DeviceOptionsDto.cs +++ b/Jellyfin.Data/Dtos/DeviceOptionsDto.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Data.Dtos +namespace Jellyfin.Data.Dtos; + +/// +/// A dto representing custom options for a device. +/// +public class DeviceOptionsDto { /// - /// A dto representing custom options for a device. + /// Gets or sets the id. /// - public class DeviceOptionsDto - { - /// - /// Gets or sets the id. - /// - public int Id { get; set; } + public int Id { get; set; } - /// - /// Gets or sets the device id. - /// - public string? DeviceId { get; set; } + /// + /// Gets or sets the device id. + /// + public string? DeviceId { get; set; } - /// - /// Gets or sets the custom name. - /// - public string? CustomName { get; set; } - } + /// + /// Gets or sets the custom name. + /// + public string? CustomName { get; set; } } diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index d7a46e2d54..d3bff2936c 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Data.Dtos; using Jellyfin.Data.Entities; using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Enums; @@ -13,6 +14,7 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Session; using Microsoft.EntityFrameworkCore; @@ -68,7 +70,7 @@ namespace Jellyfin.Server.Implementations.Devices } /// - public async Task UpdateDeviceOptions(string deviceId, string deviceName) + public async Task UpdateDeviceOptions(string deviceId, string? deviceName) { DeviceOptions? deviceOptions; var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); @@ -105,29 +107,37 @@ namespace Jellyfin.Server.Implementations.Devices } /// - public DeviceOptions GetDeviceOptions(string deviceId) + public DeviceOptionsDto? GetDeviceOptions(string deviceId) { - _deviceOptions.TryGetValue(deviceId, out var deviceOptions); + if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions)) + { + return ToDeviceOptionsDto(deviceOptions); + } - return deviceOptions ?? new DeviceOptions(deviceId); + return null; } /// - public ClientCapabilities GetCapabilities(string deviceId) + public ClientCapabilities GetCapabilities(string? deviceId) { + if (deviceId is null) + { + return new(); + } + return _capabilitiesMap.TryGetValue(deviceId, out ClientCapabilities? result) ? result - : new ClientCapabilities(); + : new(); } /// - public DeviceInfo? GetDevice(string id) + public DeviceInfoDto? GetDevice(string id) { var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault(); _deviceOptions.TryGetValue(id, out var deviceOption); var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption); - return deviceInfo; + return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo); } /// @@ -135,8 +145,8 @@ namespace Jellyfin.Server.Implementations.Devices { IEnumerable devices = _devices.Values .Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value)) - .Where(device => query.DeviceId == null || device.DeviceId == query.DeviceId) - .Where(device => query.AccessToken == null || device.AccessToken == query.AccessToken) + .Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId) + .Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken) .OrderBy(d => d.Id) .ToList(); var count = devices.Count(); @@ -166,7 +176,7 @@ namespace Jellyfin.Server.Implementations.Devices } /// - public QueryResult GetDevicesForUser(Guid? userId) + public QueryResult GetDevicesForUser(Guid? userId) { IEnumerable devices = _devices.Values .OrderByDescending(d => d.DateLastActivity) @@ -187,9 +197,11 @@ namespace Jellyfin.Server.Implementations.Devices { _deviceOptions.TryGetValue(device.DeviceId, out var option); return ToDeviceInfo(device, option); - }).ToArray(); + }) + .Select(ToDeviceInfoDto) + .ToArray(); - return new QueryResult(array); + return new QueryResult(array); } /// @@ -235,13 +247,9 @@ namespace Jellyfin.Server.Implementations.Devices private DeviceInfo ToDeviceInfo(Device authInfo, DeviceOptions? options = null) { var caps = GetCapabilities(authInfo.DeviceId); - var user = _userManager.GetUserById(authInfo.UserId); - if (user is null) - { - throw new ResourceNotFoundException("User with UserId " + authInfo.UserId + " not found"); - } + var user = _userManager.GetUserById(authInfo.UserId) ?? throw new ResourceNotFoundException("User with UserId " + authInfo.UserId + " not found"); - return new DeviceInfo + return new() { AppName = authInfo.AppName, AppVersion = authInfo.AppVersion, @@ -254,5 +262,48 @@ namespace Jellyfin.Server.Implementations.Devices CustomName = options?.CustomName, }; } + + private DeviceOptionsDto ToDeviceOptionsDto(DeviceOptions options) + { + return new() + { + Id = options.Id, + DeviceId = options.DeviceId, + CustomName = options.CustomName, + }; + } + + private DeviceInfoDto ToDeviceInfoDto(DeviceInfo info) + { + return new() + { + Name = info.Name, + CustomName = info.CustomName, + AccessToken = info.AccessToken, + Id = info.Id, + LastUserName = info.LastUserName, + AppName = info.AppName, + AppVersion = info.AppVersion, + LastUserId = info.LastUserId, + DateLastActivity = info.DateLastActivity, + Capabilities = ToClientCapabilitiesDto(info.Capabilities), + IconUrl = info.IconUrl + }; + } + + /// + public ClientCapabilitiesDto ToClientCapabilitiesDto(ClientCapabilities capabilities) + { + return new() + { + PlayableMediaTypes = capabilities.PlayableMediaTypes, + SupportedCommands = capabilities.SupportedCommands, + SupportsMediaControl = capabilities.SupportsMediaControl, + SupportsPersistentIdentifier = capabilities.SupportsPersistentIdentifier, + DeviceProfile = capabilities.DeviceProfile, + AppStoreUrl = capabilities.AppStoreUrl, + IconUrl = capabilities.IconUrl + }; + } } } diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index 7916d15c92..d641f521b9 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -1,14 +1,23 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model; using MediaBrowser.Model.MediaSegments; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Implementations.MediaSegments; @@ -17,15 +26,98 @@ namespace Jellyfin.Server.Implementations.MediaSegments; /// public class MediaSegmentManager : IMediaSegmentManager { + private readonly ILogger _logger; private readonly IDbContextFactory _dbProvider; + private readonly IMediaSegmentProvider[] _segmentProviders; + private readonly ILibraryManager _libraryManager; /// /// Initializes a new instance of the class. /// + /// Logger. /// EFCore Database factory. - public MediaSegmentManager(IDbContextFactory dbProvider) + /// List of all media segment providers. + /// Library manager. + public MediaSegmentManager( + ILogger logger, + IDbContextFactory dbProvider, + IEnumerable segmentProviders, + ILibraryManager libraryManager) { + _logger = logger; _dbProvider = dbProvider; + + _segmentProviders = segmentProviders + .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) + .ToArray(); + _libraryManager = libraryManager; + } + + /// + public async Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken) + { + var libraryOptions = _libraryManager.GetLibraryOptions(baseItem); + var providers = _segmentProviders + .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) + .OrderBy(i => + { + var index = libraryOptions.MediaSegmentProvideOrder.IndexOf(i.Name); + return index == -1 ? int.MaxValue : index; + }) + .ToList(); + + if (providers.Count == 0) + { + _logger.LogDebug("Skipping media segment extraction as no providers are enabled for {MediaPath}", baseItem.Path); + return; + } + + using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + if (!overwrite && (await db.MediaSegments.AnyAsync(e => e.ItemId.Equals(baseItem.Id), cancellationToken).ConfigureAwait(false))) + { + _logger.LogDebug("Skip {MediaPath} as it already contains media segments", baseItem.Path); + return; + } + + _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count); + + await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + + // no need to recreate the request object every time. + var requestItem = new MediaSegmentGenerationRequest() { ItemId = baseItem.Id }; + + foreach (var provider in providers) + { + if (!await provider.Supports(baseItem).ConfigureAwait(false)) + { + _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path); + continue; + } + + try + { + var segments = await provider.GetMediaSegments(requestItem, cancellationToken) + .ConfigureAwait(false); + if (segments.Count == 0) + { + _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path); + continue; + } + + _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path); + var providerId = GetProviderId(provider.Name); + foreach (var segment in segments) + { + segment.ItemId = baseItem.Id; + await CreateSegmentAsync(segment, providerId).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path); + } + } } /// @@ -103,4 +195,21 @@ public class MediaSegmentManager : IMediaSegmentManager { return baseItem.MediaType is Data.Enums.MediaType.Video or Data.Enums.MediaType.Audio; } + + /// + public IEnumerable<(string Name, string Id)> GetSupportedProviders(BaseItem item) + { + if (item is not (Video or Audio)) + { + return []; + } + + return _segmentProviders + .Select(p => (p.Name, GetProviderId(p.Name))); + } + + private string GetProviderId(string name) + => name.ToLowerInvariant() + .GetMD5() + .ToString("N", CultureInfo.InvariantCulture); } diff --git a/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs new file mode 100644 index 0000000000..8dba31a67f --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.Designer.cs @@ -0,0 +1,712 @@ +// +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("20240928082930_MarkSegmentProviderIdNonNullable")] + partial class MarkSegmentProviderIdNonNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("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/20240928082930_MarkSegmentProviderIdNonNullable.cs b/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs new file mode 100644 index 0000000000..55b90a54d7 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20240928082930_MarkSegmentProviderIdNonNullable.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class MarkSegmentProviderIdNonNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SegmentProviderId", + table: "MediaSegments", + type: "TEXT", + nullable: false, + defaultValue: string.Empty, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "SegmentProviderId", + table: "MediaSegments", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index 399e2a08ac..f6191dd2cd 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -282,15 +282,16 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property("ItemId") .HasColumnType("TEXT"); + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("StartTicks") .HasColumnType("INTEGER"); b.Property("Type") .HasColumnType("INTEGER"); - b.Property("SegmentProviderId") - .HasColumnType("TEXT"); - b.HasKey("Id"); b.ToTable("MediaSegments"); diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index bb32b7c20e..f6c48498ca 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -76,7 +76,65 @@ public class TrickplayManager : ITrickplayManager } /// - public async Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken) + public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken) + { + var options = _config.Configuration.TrickplayOptions; + if (!CanGenerateTrickplay(video, options.Interval)) + { + return; + } + + var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false); + foreach (var resolution in existingTrickplayResolutions) + { + cancellationToken.ThrowIfCancellationRequested(); + var existingResolution = resolution.Key; + var tileWidth = resolution.Value.TileWidth; + var tileHeight = resolution.Value.TileHeight; + var shouldBeSavedWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia; + var localOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false); + var mediaOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true); + if (shouldBeSavedWithMedia && Directory.Exists(localOutputDir)) + { + var localDirFiles = Directory.GetFiles(localOutputDir); + var mediaDirExists = Directory.Exists(mediaOutputDir); + if (localDirFiles.Length > 0 && ((mediaDirExists && Directory.GetFiles(mediaOutputDir).Length == 0) || !mediaDirExists)) + { + // Move images from local dir to media dir + MoveContent(localOutputDir, mediaOutputDir); + _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutputDir); + } + } + else if (!shouldBeSavedWithMedia && Directory.Exists(mediaOutputDir)) + { + var mediaDirFiles = Directory.GetFiles(mediaOutputDir); + var localDirExists = Directory.Exists(localOutputDir); + if (mediaDirFiles.Length > 0 && ((localDirExists && Directory.GetFiles(localOutputDir).Length == 0) || !localDirExists)) + { + // Move images from media dir to local dir + MoveContent(mediaOutputDir, localOutputDir); + _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutputDir); + } + } + } + } + + private void MoveContent(string sourceFolder, string destinationFolder) + { + _fileSystem.MoveDirectory(sourceFolder, destinationFolder); + var parent = Directory.GetParent(sourceFolder); + if (parent is not null) + { + var parentContent = Directory.GetDirectories(parent.FullName); + if (parentContent.Length == 0) + { + Directory.Delete(parent.FullName); + } + } + } + + /// + public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken) { _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace); @@ -95,6 +153,7 @@ public class TrickplayManager : ITrickplayManager replace, width, options, + libraryOptions, cancellationToken).ConfigureAwait(false); } } @@ -104,6 +163,7 @@ public class TrickplayManager : ITrickplayManager bool replace, int width, TrickplayOptions options, + LibraryOptions? libraryOptions, CancellationToken cancellationToken) { if (!CanGenerateTrickplay(video, options.Interval)) @@ -144,14 +204,53 @@ public class TrickplayManager : ITrickplayManager actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2); } - var outputDir = GetTrickplayDirectory(video, actualWidth); + var tileWidth = options.TileWidth; + var tileHeight = options.TileHeight; + var saveWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia; + var outputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia); - if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(actualWidth)) + // Import existing trickplay tiles + if (!replace && Directory.Exists(outputDir)) { - _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting", video.Id); - return; + var existingFiles = Directory.GetFiles(outputDir); + if (existingFiles.Length > 0) + { + var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureAwait(false); + if (hasTrickplayResolution) + { + _logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id); + return; + } + + // Import tiles + var localTrickplayInfo = new TrickplayInfo + { + ItemId = video.Id, + Width = width, + Interval = options.Interval, + TileWidth = options.TileWidth, + TileHeight = options.TileHeight, + ThumbnailCount = existingFiles.Length, + Height = 0, + Bandwidth = 0 + }; + + foreach (var tile in existingFiles) + { + var image = _imageEncoder.GetImageSize(tile); + localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, image.Height); + var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000)); + localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate); + } + + await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false); + + _logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id); + return; + } } + // Generate trickplay tiles var mediaStream = mediaSource.VideoStream; var container = mediaSource.Container; @@ -224,7 +323,7 @@ public class TrickplayManager : ITrickplayManager } /// - public TrickplayInfo CreateTiles(List images, int width, TrickplayOptions options, string outputDir) + public TrickplayInfo CreateTiles(IReadOnlyList images, int width, TrickplayOptions options, string outputDir) { if (images.Count == 0) { @@ -264,7 +363,7 @@ public class TrickplayManager : ITrickplayManager var tilePath = Path.Combine(workDir, $"{i}.jpg"); imageOptions.OutputPath = tilePath; - imageOptions.InputPaths = images.GetRange(i * thumbnailsPerTile, Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))); + imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))).ToList(); // Generate image and use returned height for tiles info var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null); @@ -289,7 +388,7 @@ public class TrickplayManager : ITrickplayManager Directory.Delete(outputDir, true); } - MoveDirectory(workDir, outputDir); + _fileSystem.MoveDirectory(workDir, outputDir); return trickplayInfo; } @@ -355,6 +454,26 @@ public class TrickplayManager : ITrickplayManager return trickplayResolutions; } + /// + public async Task> GetTrickplayItemsAsync(int limit, int offset) + { + IReadOnlyList trickplayItems; + + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + trickplayItems = await dbContext.TrickplayInfos + .AsNoTracking() + .OrderBy(i => i.ItemId) + .Skip(offset) + .Take(limit) + .ToListAsync() + .ConfigureAwait(false); + } + + return trickplayItems; + } + /// public async Task SaveTrickplayInfo(TrickplayInfo info) { @@ -392,9 +511,15 @@ public class TrickplayManager : ITrickplayManager } /// - public string GetTrickplayTilePath(BaseItem item, int width, int index) + public async Task GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia) { - return Path.Combine(GetTrickplayDirectory(item, width), index + ".jpg"); + var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false); + if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo)) + { + return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, saveWithMedia), index + ".jpg"); + } + + return string.Empty; } /// @@ -470,29 +595,33 @@ public class TrickplayManager : ITrickplayManager return null; } - private string GetTrickplayDirectory(BaseItem item, int? width = null) + /// + public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false) { - var path = Path.Combine(item.GetInternalMetadataPath(), "trickplay"); + var path = saveWithMedia + ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay")) + : Path.Combine(item.GetInternalMetadataPath(), "trickplay"); - return width.HasValue ? Path.Combine(path, width.Value.ToString(CultureInfo.InvariantCulture)) : path; + var subdirectory = string.Format( + CultureInfo.InvariantCulture, + "{0} - {1}x{2}", + width.ToString(CultureInfo.InvariantCulture), + tileWidth.ToString(CultureInfo.InvariantCulture), + tileHeight.ToString(CultureInfo.InvariantCulture)); + + return Path.Combine(path, subdirectory); } - private void MoveDirectory(string source, string destination) + private async Task HasTrickplayResolutionAsync(Guid itemId, int width) { - try + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - 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); + return await dbContext.TrickplayInfos + .AsNoTracking() + .Where(i => i.ItemId.Equals(itemId)) + .AnyAsync(i => i.Width == width) + .ConfigureAwait(false); } } } diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 81fecc9a13..9d4441ac39 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -23,7 +23,8 @@ namespace Jellyfin.Server.Migrations { typeof(PreStartupRoutines.CreateNetworkConfiguration), typeof(PreStartupRoutines.MigrateMusicBrainzTimeout), - typeof(PreStartupRoutines.MigrateNetworkConfiguration) + typeof(PreStartupRoutines.MigrateNetworkConfiguration), + typeof(PreStartupRoutines.MigrateEncodingOptions) }; /// @@ -46,6 +47,7 @@ namespace Jellyfin.Server.Migrations typeof(Routines.AddDefaultCastReceivers), typeof(Routines.UpdateDefaultPluginRepository), typeof(Routines.FixAudioData), + typeof(Routines.MoveTrickplayFiles) }; /// diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs index 139a6ec640..8462d0a8c9 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs @@ -132,5 +132,4 @@ public class CreateNetworkConfiguration : IMigrationRoutine public string[] KnownProxies { get; set; } = Array.Empty(); } -#pragma warning restore } diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs new file mode 100644 index 0000000000..61f5620dc0 --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs @@ -0,0 +1,245 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; +using Emby.Server.Implementations; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.PreStartupRoutines; + +/// +public class MigrateEncodingOptions : IMigrationRoutine +{ + private readonly ServerApplicationPaths _applicationPaths; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// An instance of . + /// An instance of the interface. + public MigrateEncodingOptions(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory) + { + _applicationPaths = applicationPaths; + _logger = loggerFactory.CreateLogger(); + } + + /// + public Guid Id => Guid.Parse("A8E61960-7726-4450-8F3D-82C12DAABBCB"); + + /// + public string Name => nameof(MigrateEncodingOptions); + + /// + public bool PerformOnNewInstall => false; + + /// + public void Perform() + { + string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "encoding.xml"); + var oldSerializer = new XmlSerializer(typeof(OldEncodingOptions), new XmlRootAttribute("EncodingOptions")); + OldEncodingOptions? oldConfig = null; + + try + { + using var xmlReader = XmlReader.Create(path); + oldConfig = (OldEncodingOptions?)oldSerializer.Deserialize(xmlReader); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Migrate EncodingOptions deserialize Invalid Operation error"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Migrate EncodingOptions deserialize error"); + } + + if (oldConfig is null) + { + return; + } + + var hardwareAccelerationType = HardwareAccelerationType.none; + if (Enum.TryParse(oldConfig.HardwareAccelerationType, true, out var parsedHardwareAccelerationType)) + { + hardwareAccelerationType = parsedHardwareAccelerationType; + } + + var tonemappingAlgorithm = TonemappingAlgorithm.none; + if (Enum.TryParse(oldConfig.TonemappingAlgorithm, true, out var parsedTonemappingAlgorithm)) + { + tonemappingAlgorithm = parsedTonemappingAlgorithm; + } + + var tonemappingMode = TonemappingMode.auto; + if (Enum.TryParse(oldConfig.TonemappingMode, true, out var parsedTonemappingMode)) + { + tonemappingMode = parsedTonemappingMode; + } + + var tonemappingRange = TonemappingRange.auto; + if (Enum.TryParse(oldConfig.TonemappingRange, true, out var parsedTonemappingRange)) + { + tonemappingRange = parsedTonemappingRange; + } + + var encoderPreset = EncoderPreset.superfast; + if (Enum.TryParse(oldConfig.TonemappingRange, true, out var parsedEncoderPreset)) + { + encoderPreset = parsedEncoderPreset; + } + + var deinterlaceMethod = DeinterlaceMethod.yadif; + if (Enum.TryParse(oldConfig.TonemappingRange, true, out var parsedDeinterlaceMethod)) + { + deinterlaceMethod = parsedDeinterlaceMethod; + } + + var encodingOptions = new EncodingOptions() + { + EncodingThreadCount = oldConfig.EncodingThreadCount, + TranscodingTempPath = oldConfig.TranscodingTempPath, + FallbackFontPath = oldConfig.FallbackFontPath, + EnableFallbackFont = oldConfig.EnableFallbackFont, + EnableAudioVbr = oldConfig.EnableAudioVbr, + DownMixAudioBoost = oldConfig.DownMixAudioBoost, + DownMixStereoAlgorithm = oldConfig.DownMixStereoAlgorithm, + MaxMuxingQueueSize = oldConfig.MaxMuxingQueueSize, + EnableThrottling = oldConfig.EnableThrottling, + ThrottleDelaySeconds = oldConfig.ThrottleDelaySeconds, + EnableSegmentDeletion = oldConfig.EnableSegmentDeletion, + SegmentKeepSeconds = oldConfig.SegmentKeepSeconds, + HardwareAccelerationType = hardwareAccelerationType, + EncoderAppPath = oldConfig.EncoderAppPath, + EncoderAppPathDisplay = oldConfig.EncoderAppPathDisplay, + VaapiDevice = oldConfig.VaapiDevice, + EnableTonemapping = oldConfig.EnableTonemapping, + EnableVppTonemapping = oldConfig.EnableVppTonemapping, + EnableVideoToolboxTonemapping = oldConfig.EnableVideoToolboxTonemapping, + TonemappingAlgorithm = tonemappingAlgorithm, + TonemappingMode = tonemappingMode, + TonemappingRange = tonemappingRange, + TonemappingDesat = oldConfig.TonemappingDesat, + TonemappingPeak = oldConfig.TonemappingPeak, + TonemappingParam = oldConfig.TonemappingParam, + VppTonemappingBrightness = oldConfig.VppTonemappingBrightness, + VppTonemappingContrast = oldConfig.VppTonemappingContrast, + H264Crf = oldConfig.H264Crf, + H265Crf = oldConfig.H265Crf, + EncoderPreset = encoderPreset, + DeinterlaceDoubleRate = oldConfig.DeinterlaceDoubleRate, + DeinterlaceMethod = deinterlaceMethod, + EnableDecodingColorDepth10Hevc = oldConfig.EnableDecodingColorDepth10Hevc, + EnableDecodingColorDepth10Vp9 = oldConfig.EnableDecodingColorDepth10Vp9, + EnableEnhancedNvdecDecoder = oldConfig.EnableEnhancedNvdecDecoder, + PreferSystemNativeHwDecoder = oldConfig.PreferSystemNativeHwDecoder, + EnableIntelLowPowerH264HwEncoder = oldConfig.EnableIntelLowPowerH264HwEncoder, + EnableIntelLowPowerHevcHwEncoder = oldConfig.EnableIntelLowPowerHevcHwEncoder, + EnableHardwareEncoding = oldConfig.EnableHardwareEncoding, + AllowHevcEncoding = oldConfig.AllowHevcEncoding, + AllowAv1Encoding = oldConfig.AllowAv1Encoding, + EnableSubtitleExtraction = oldConfig.EnableSubtitleExtraction, + HardwareDecodingCodecs = oldConfig.HardwareDecodingCodecs, + AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = oldConfig.AllowOnDemandMetadataBasedKeyframeExtractionForExtensions + }; + + var newSerializer = new XmlSerializer(typeof(EncodingOptions)); + var xmlWriterSettings = new XmlWriterSettings { Indent = true }; + using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings); + newSerializer.Serialize(xmlWriter, encodingOptions); + } + +#pragma warning disable + public sealed class OldEncodingOptions + { + public int EncodingThreadCount { get; set; } + + public string TranscodingTempPath { get; set; } + + public string FallbackFontPath { get; set; } + + public bool EnableFallbackFont { get; set; } + + public bool EnableAudioVbr { get; set; } + + public double DownMixAudioBoost { get; set; } + + public DownMixStereoAlgorithms DownMixStereoAlgorithm { get; set; } + + public int MaxMuxingQueueSize { get; set; } + + public bool EnableThrottling { get; set; } + + public int ThrottleDelaySeconds { get; set; } + + public bool EnableSegmentDeletion { get; set; } + + public int SegmentKeepSeconds { get; set; } + + public string HardwareAccelerationType { get; set; } + + public string EncoderAppPath { get; set; } + + public string EncoderAppPathDisplay { get; set; } + + public string VaapiDevice { get; set; } + + public bool EnableTonemapping { get; set; } + + public bool EnableVppTonemapping { get; set; } + + public bool EnableVideoToolboxTonemapping { get; set; } + + public string TonemappingAlgorithm { get; set; } + + public string TonemappingMode { get; set; } + + public string TonemappingRange { get; set; } + + public double TonemappingDesat { get; set; } + + public double TonemappingPeak { get; set; } + + public double TonemappingParam { get; set; } + + public double VppTonemappingBrightness { get; set; } + + public double VppTonemappingContrast { get; set; } + + public int H264Crf { get; set; } + + public int H265Crf { get; set; } + + public string EncoderPreset { get; set; } + + public bool DeinterlaceDoubleRate { get; set; } + + public string DeinterlaceMethod { get; set; } + + public bool EnableDecodingColorDepth10Hevc { get; set; } + + public bool EnableDecodingColorDepth10Vp9 { get; set; } + + public bool EnableEnhancedNvdecDecoder { get; set; } + + public bool PreferSystemNativeHwDecoder { get; set; } + + public bool EnableIntelLowPowerH264HwEncoder { get; set; } + + public bool EnableIntelLowPowerHevcHwEncoder { get; set; } + + public bool EnableHardwareEncoding { get; set; } + + public bool AllowHevcEncoding { get; set; } + + public bool AllowAv1Encoding { get; set; } + + public bool EnableSubtitleExtraction { get; set; } + + public string[] HardwareDecodingCodecs { get; set; } + + public string[] AllowOnDemandMetadataBasedKeyframeExtractionForExtensions { get; set; } + } +} diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs index 0544fe561a..580282a5f5 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs @@ -48,9 +48,11 @@ public class MigrateMusicBrainzTimeout : IMigrationRoutine if (oldPluginConfiguration is not null) { - var newPluginConfiguration = new PluginConfiguration(); - newPluginConfiguration.Server = oldPluginConfiguration.Server; - newPluginConfiguration.ReplaceArtistName = oldPluginConfiguration.ReplaceArtistName; + var newPluginConfiguration = new PluginConfiguration + { + Server = oldPluginConfiguration.Server, + ReplaceArtistName = oldPluginConfiguration.ReplaceArtistName + }; var newRateLimit = oldPluginConfiguration.RateLimit / 1000.0; newPluginConfiguration.RateLimit = newRateLimit < 1.0 ? 1.0 : newRateLimit; WriteNew(path, newPluginConfiguration); @@ -93,6 +95,4 @@ public class MigrateMusicBrainzTimeout : IMigrationRoutine public bool ReplaceArtistName { get; set; } } -#pragma warning restore - } diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs index d92c00991b..49960f4305 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs @@ -55,49 +55,53 @@ public class MigrateNetworkConfiguration : IMigrationRoutine _logger.LogError(ex, "Migrate NetworkConfiguration deserialize error"); } - if (oldNetworkConfiguration is not null) + if (oldNetworkConfiguration is null) { - // Migrate network config values to new config schema - var networkConfiguration = new NetworkConfiguration(); - networkConfiguration.AutoDiscovery = oldNetworkConfiguration.AutoDiscovery; - networkConfiguration.BaseUrl = oldNetworkConfiguration.BaseUrl; - networkConfiguration.CertificatePassword = oldNetworkConfiguration.CertificatePassword; - networkConfiguration.CertificatePath = oldNetworkConfiguration.CertificatePath; - networkConfiguration.EnableHttps = oldNetworkConfiguration.EnableHttps; - networkConfiguration.EnableIPv4 = oldNetworkConfiguration.EnableIPV4; - networkConfiguration.EnableIPv6 = oldNetworkConfiguration.EnableIPV6; - networkConfiguration.EnablePublishedServerUriByRequest = oldNetworkConfiguration.EnablePublishedServerUriByRequest; - networkConfiguration.EnableRemoteAccess = oldNetworkConfiguration.EnableRemoteAccess; - networkConfiguration.EnableUPnP = oldNetworkConfiguration.EnableUPnP; - networkConfiguration.IgnoreVirtualInterfaces = oldNetworkConfiguration.IgnoreVirtualInterfaces; - networkConfiguration.InternalHttpPort = oldNetworkConfiguration.HttpServerPortNumber; - networkConfiguration.InternalHttpsPort = oldNetworkConfiguration.HttpsPortNumber; - networkConfiguration.IsRemoteIPFilterBlacklist = oldNetworkConfiguration.IsRemoteIPFilterBlacklist; - networkConfiguration.KnownProxies = oldNetworkConfiguration.KnownProxies; - networkConfiguration.LocalNetworkAddresses = oldNetworkConfiguration.LocalNetworkAddresses; - networkConfiguration.LocalNetworkSubnets = oldNetworkConfiguration.LocalNetworkSubnets; - networkConfiguration.PublicHttpPort = oldNetworkConfiguration.PublicPort; - networkConfiguration.PublicHttpsPort = oldNetworkConfiguration.PublicHttpsPort; - networkConfiguration.PublishedServerUriBySubnet = oldNetworkConfiguration.PublishedServerUriBySubnet; - networkConfiguration.RemoteIPFilter = oldNetworkConfiguration.RemoteIPFilter; - networkConfiguration.RequireHttps = oldNetworkConfiguration.RequireHttps; - - // Migrate old virtual interface name schema - var oldVirtualInterfaceNames = oldNetworkConfiguration.VirtualInterfaceNames; - if (oldVirtualInterfaceNames.Equals("vEthernet*", StringComparison.OrdinalIgnoreCase)) - { - networkConfiguration.VirtualInterfaceNames = new string[] { "veth" }; - } - else - { - networkConfiguration.VirtualInterfaceNames = oldVirtualInterfaceNames.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase).Split(','); - } - - var networkConfigSerializer = new XmlSerializer(typeof(NetworkConfiguration)); - var xmlWriterSettings = new XmlWriterSettings { Indent = true }; - using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings); - networkConfigSerializer.Serialize(xmlWriter, networkConfiguration); + return; } + + // Migrate network config values to new config schema + var networkConfiguration = new NetworkConfiguration + { + AutoDiscovery = oldNetworkConfiguration.AutoDiscovery, + BaseUrl = oldNetworkConfiguration.BaseUrl, + CertificatePassword = oldNetworkConfiguration.CertificatePassword, + CertificatePath = oldNetworkConfiguration.CertificatePath, + EnableHttps = oldNetworkConfiguration.EnableHttps, + EnableIPv4 = oldNetworkConfiguration.EnableIPV4, + EnableIPv6 = oldNetworkConfiguration.EnableIPV6, + EnablePublishedServerUriByRequest = oldNetworkConfiguration.EnablePublishedServerUriByRequest, + EnableRemoteAccess = oldNetworkConfiguration.EnableRemoteAccess, + EnableUPnP = oldNetworkConfiguration.EnableUPnP, + IgnoreVirtualInterfaces = oldNetworkConfiguration.IgnoreVirtualInterfaces, + InternalHttpPort = oldNetworkConfiguration.HttpServerPortNumber, + InternalHttpsPort = oldNetworkConfiguration.HttpsPortNumber, + IsRemoteIPFilterBlacklist = oldNetworkConfiguration.IsRemoteIPFilterBlacklist, + KnownProxies = oldNetworkConfiguration.KnownProxies, + LocalNetworkAddresses = oldNetworkConfiguration.LocalNetworkAddresses, + LocalNetworkSubnets = oldNetworkConfiguration.LocalNetworkSubnets, + PublicHttpPort = oldNetworkConfiguration.PublicPort, + PublicHttpsPort = oldNetworkConfiguration.PublicHttpsPort, + PublishedServerUriBySubnet = oldNetworkConfiguration.PublishedServerUriBySubnet, + RemoteIPFilter = oldNetworkConfiguration.RemoteIPFilter, + RequireHttps = oldNetworkConfiguration.RequireHttps + }; + + // Migrate old virtual interface name schema + var oldVirtualInterfaceNames = oldNetworkConfiguration.VirtualInterfaceNames; + if (oldVirtualInterfaceNames.Equals("vEthernet*", StringComparison.OrdinalIgnoreCase)) + { + networkConfiguration.VirtualInterfaceNames = new string[] { "veth" }; + } + else + { + networkConfiguration.VirtualInterfaceNames = oldVirtualInterfaceNames.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase).Split(','); + } + + var networkConfigSerializer = new XmlSerializer(typeof(NetworkConfiguration)); + var xmlWriterSettings = new XmlWriterSettings { Indent = true }; + using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings); + networkConfigSerializer.Serialize(xmlWriter, networkConfiguration); } #pragma warning disable @@ -204,5 +208,4 @@ public class MigrateNetworkConfiguration : IMigrationRoutine public bool EnablePublishedServerUriByRequest { get; set; } = false; } -#pragma warning restore } diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs new file mode 100644 index 0000000000..c1a9e88949 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs @@ -0,0 +1,104 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migration to move trickplay files to the new directory. +/// +public class MoveTrickplayFiles : IMigrationRoutine +{ + private readonly ITrickplayManager _trickplayManager; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// The logger. + public MoveTrickplayFiles(ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILogger logger) + { + _trickplayManager = trickplayManager; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _logger = logger; + } + + /// + public Guid Id => new("4EF123D5-8EFF-4B0B-869D-3AED07A60E1B"); + + /// + public string Name => "MoveTrickplayFiles"; + + /// + public bool PerformOnNewInstall => true; + + /// + public void Perform() + { + const int Limit = 100; + int itemCount = 0, offset = 0, previousCount; + + var sw = Stopwatch.StartNew(); + var trickplayQuery = new InternalItemsQuery + { + MediaTypes = [MediaType.Video], + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + IsFolder = false + }; + + do + { + var trickplayInfos = _trickplayManager.GetTrickplayItemsAsync(Limit, offset).GetAwaiter().GetResult(); + previousCount = trickplayInfos.Count; + offset += Limit; + + trickplayQuery.ItemIds = trickplayInfos.Select(i => i.ItemId).Distinct().ToArray(); + var items = _libraryManager.GetItemList(trickplayQuery); + foreach (var trickplayInfo in trickplayInfos) + { + var item = items.OfType public const string SqliteCacheSizeKey = "sqlite:cacheSize"; + /// + /// The key for a setting that indicates whether the application should detect network status change. + /// + public const string DetectNetworkChangeKey = "DetectNetworkChange"; + /// /// Gets a value indicating whether the application should host static web content from the . /// @@ -102,6 +112,14 @@ namespace MediaBrowser.Controller.Extensions public static bool GetFFmpegSkipValidation(this IConfiguration configuration) => configuration.GetValue(FfmpegSkipValidationKey); + /// + /// Gets a value indicating whether the server should trade off for performance during FFmpeg image extraction. + /// + /// The configuration to read the setting from. + /// true if the server should trade off for performance during FFmpeg image extraction, otherwise false. + public static bool GetFFmpegImgExtractPerfTradeoff(this IConfiguration configuration) + => configuration.GetValue(FfmpegImgExtractPerfTradeoffKey); + /// /// Gets a value indicating whether playlists should allow duplicate entries from the . /// diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index f77186e25c..20f51ddb71 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -193,6 +193,8 @@ namespace MediaBrowser.Controller.MediaEncoding public bool EnableAudioVbrEncoding { get; set; } + public bool AlwaysBurnInSubtitleWhenTranscoding { get; set; } + public string GetOption(string qualifier, string name) { var value = GetOption(qualifier + "-" + name); diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 24cd141dcd..c120e08fa2 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -37,6 +37,8 @@ namespace MediaBrowser.Controller.MediaEncoding /// public const string ValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$"; + private const string _defaultMjpegEncoder = "mjpeg"; + private const string QsvAlias = "qs"; private const string VaapiAlias = "va"; private const string D3d11vaAlias = "dx11"; @@ -69,11 +71,14 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegAdvancedTonemapMode = new Version(7, 0, 1); private readonly Version _minFFmpegAlteredVaVkInterop = new Version(7, 0, 1); private readonly Version _minFFmpegQsvVppTonemapOption = new Version(7, 0, 1); + private readonly Version _minFFmpegQsvVppOutRangeOption = new Version(7, 0, 1); + private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1); + private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0); private static readonly Regex _validationRegex = new(ValidationRegex, RegexOptions.Compiled); - private static readonly string[] _videoProfilesH264 = new[] - { + private static readonly string[] _videoProfilesH264 = + [ "ConstrainedBaseline", "Baseline", "Extended", @@ -82,20 +87,20 @@ namespace MediaBrowser.Controller.MediaEncoding "ProgressiveHigh", "ConstrainedHigh", "High10" - }; + ]; - private static readonly string[] _videoProfilesH265 = new[] - { + private static readonly string[] _videoProfilesH265 = + [ "Main", "Main10" - }; + ]; - private static readonly string[] _videoProfilesAv1 = new[] - { + private static readonly string[] _videoProfilesAv1 = + [ "Main", "High", "Professional", - }; + ]; private static readonly HashSet _mp4ContainerNames = new(StringComparer.OrdinalIgnoreCase) { @@ -107,8 +112,8 @@ namespace MediaBrowser.Controller.MediaEncoding "m4v", }; - private static readonly string[] _legacyTonemapModes = new[] { "max", "rgb" }; - private static readonly string[] _advancedTonemapModes = new[] { "lum", "itp" }; + private static readonly TonemappingMode[] _legacyTonemapModes = [TonemappingMode.max, TonemappingMode.rgb]; + private static readonly TonemappingMode[] _advancedTonemapModes = [TonemappingMode.lum, TonemappingMode.itp]; // Set max transcoding channels for encoders that can't handle more than a set amount of channels // AAC, FLAC, ALAC, libopus, libvorbis encoders all support at least 8 channels @@ -123,23 +128,23 @@ namespace MediaBrowser.Controller.MediaEncoding { "truehd", 6 }, }; - private static readonly string _defaultMjpegEncoder = "mjpeg"; - private static readonly Dictionary _mjpegCodecMap = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary _mjpegCodecMap = new() { - { "vaapi", _defaultMjpegEncoder + "_vaapi" }, - { "qsv", _defaultMjpegEncoder + "_qsv" }, - { "videotoolbox", _defaultMjpegEncoder + "_videotoolbox" } + { HardwareAccelerationType.vaapi, _defaultMjpegEncoder + "_vaapi" }, + { HardwareAccelerationType.qsv, _defaultMjpegEncoder + "_qsv" }, + { HardwareAccelerationType.videotoolbox, _defaultMjpegEncoder + "_videotoolbox" }, + { HardwareAccelerationType.rkmpp, _defaultMjpegEncoder + "_rkmpp" } }; - public static readonly string[] LosslessAudioCodecs = new string[] - { + public static readonly string[] LosslessAudioCodecs = + [ "alac", "ape", "flac", "mlp", "truehd", "wavpack" - }; + ]; public EncodingHelper( IApplicationPaths appPaths, @@ -176,18 +181,18 @@ namespace MediaBrowser.Controller.MediaEncoding { var hwType = encodingOptions.HardwareAccelerationType; - var codecMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + var codecMap = new Dictionary() { - { "amf", hwEncoder + "_amf" }, - { "nvenc", hwEncoder + "_nvenc" }, - { "qsv", hwEncoder + "_qsv" }, - { "vaapi", hwEncoder + "_vaapi" }, - { "videotoolbox", hwEncoder + "_videotoolbox" }, - { "v4l2m2m", hwEncoder + "_v4l2m2m" }, - { "rkmpp", hwEncoder + "_rkmpp" }, + { HardwareAccelerationType.amf, hwEncoder + "_amf" }, + { HardwareAccelerationType.nvenc, hwEncoder + "_nvenc" }, + { HardwareAccelerationType.qsv, hwEncoder + "_qsv" }, + { HardwareAccelerationType.vaapi, hwEncoder + "_vaapi" }, + { HardwareAccelerationType.videotoolbox, hwEncoder + "_videotoolbox" }, + { HardwareAccelerationType.v4l2m2m, hwEncoder + "_v4l2m2m" }, + { HardwareAccelerationType.rkmpp, hwEncoder + "_rkmpp" }, }; - if (!string.IsNullOrEmpty(hwType) + if (hwType != HardwareAccelerationType.none && encodingOptions.EnableHardwareEncoding && codecMap.TryGetValue(hwType, out var preferredEncoder) && _mediaEncoder.SupportsEncoder(preferredEncoder)) @@ -205,7 +210,15 @@ namespace MediaBrowser.Controller.MediaEncoding { var hwType = encodingOptions.HardwareAccelerationType; - if (!string.IsNullOrEmpty(hwType) + // Only Intel has VA-API MJPEG encoder + if (hwType == HardwareAccelerationType.vaapi + && !(_mediaEncoder.IsVaapiDeviceInteliHD + || _mediaEncoder.IsVaapiDeviceInteli965)) + { + return _defaultMjpegEncoder; + } + + if (hwType != HardwareAccelerationType.none && encodingOptions.EnableHardwareEncoding && _mjpegCodecMap.TryGetValue(hwType, out var preferredEncoder) && _mediaEncoder.SupportsEncoder(preferredEncoder)) @@ -297,7 +310,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (state.VideoStream is null || !options.EnableTonemapping - || GetVideoColorBitDepth(state) != 10 + || GetVideoColorBitDepth(state) < 10 || !_mediaEncoder.SupportsFilter("tonemapx")) { return false; @@ -310,13 +323,12 @@ namespace MediaBrowser.Controller.MediaEncoding { if (state.VideoStream is null || !options.EnableTonemapping - || GetVideoColorBitDepth(state) != 10) + || GetVideoColorBitDepth(state) < 10) { return false; } - if (string.Equals(state.VideoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) - && state.VideoStream.VideoRange == VideoRange.HDR + if (state.VideoStream.VideoRange == VideoRange.HDR && state.VideoStream.VideoRangeType == VideoRangeType.DOVI) { // Only native SW decoder and HW accelerator can parse dovi rpu. @@ -353,7 +365,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (state.VideoStream is null || !options.EnableVppTonemapping - || GetVideoColorBitDepth(state) != 10) + || GetVideoColorBitDepth(state) < 10) { return false; } @@ -361,7 +373,7 @@ namespace MediaBrowser.Controller.MediaEncoding // prefer 'tonemap_vaapi' over 'vpp_qsv' on Linux for supporting Gen9/KBLx. // 'vpp_qsv' requires VPL, which is only supported on Gen12/TGLx and newer. if (OperatingSystem.IsWindows() - && string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) + && options.HardwareAccelerationType == HardwareAccelerationType.qsv && _mediaEncoder.EncoderVersion < _minFFmpegQsvVppTonemapOption) { return false; @@ -376,7 +388,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (state.VideoStream is null || !options.EnableVideoToolboxTonemapping - || GetVideoColorBitDepth(state) != 10) + || GetVideoColorBitDepth(state) < 10) { return false; } @@ -387,6 +399,25 @@ namespace MediaBrowser.Controller.MediaEncoding && state.VideoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG or VideoRangeType.HDR10Plus or VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithHLG; } + private bool IsVideoStreamHevcRext(EncodingJobInfo state) + { + var videoStream = state.VideoStream; + if (videoStream is null) + { + return false; + } + + return string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) + && (string.Equals(videoStream.Profile, "Rext", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv420p12le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv422p", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv422p10le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv422p12le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p10le", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.PixelFormat, "yuv444p12le", StringComparison.OrdinalIgnoreCase)); + } + /// /// Gets the name of the output video codec. /// @@ -851,13 +882,15 @@ namespace MediaBrowser.Controller.MediaEncoding options); } - private string GetVaapiDeviceArgs(string renderNodePath, string driver, string kernelDriver, string srcDeviceAlias, string alias) + private string GetVaapiDeviceArgs(string renderNodePath, string driver, string kernelDriver, string vendorId, string srcDeviceAlias, string alias) { alias ??= VaapiAlias; + var haveVendorId = !string.IsNullOrEmpty(vendorId) + && _mediaEncoder.EncoderVersion >= _minFFmpegVaapiDeviceVendorId; - // 'renderNodePath' has higher priority than 'kernelDriver' + // Priority: 'renderNodePath' > 'vendorId' > 'kernelDriver' var driverOpts = string.IsNullOrEmpty(renderNodePath) - ? (string.IsNullOrEmpty(kernelDriver) ? string.Empty : ",kernel_driver=" + kernelDriver) + ? (haveVendorId ? $",vendor_id={vendorId}" : (string.IsNullOrEmpty(kernelDriver) ? string.Empty : $",kernel_driver={kernelDriver}")) : renderNodePath; // 'driver' behaves similarly to env LIBVA_DRIVER_NAME @@ -892,7 +925,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (OperatingSystem.IsLinux()) { // derive qsv from vaapi device - return GetVaapiDeviceArgs(renderNodePath, "iHD", "i915", null, VaapiAlias) + arg + "@" + VaapiAlias; + return GetVaapiDeviceArgs(renderNodePath, "iHD", "i915", "0x8086", null, VaapiAlias) + arg + "@" + VaapiAlias; } if (OperatingSystem.IsWindows()) @@ -921,7 +954,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // DVBSUB uses the fixed canvas size 720x576 if (state.SubtitleStream is not null - && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode + && ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream && !string.Equals(state.SubtitleStream.Codec, "DVBSUB", StringComparison.OrdinalIgnoreCase)) { @@ -971,7 +1004,7 @@ namespace MediaBrowser.Controller.MediaEncoding var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; var isHwTonemapAvailable = IsHwTonemapAvailable(state, options); - if (string.Equals(optHwaccelType, "vaapi", StringComparison.OrdinalIgnoreCase)) + if (optHwaccelType == HardwareAccelerationType.vaapi) { if (!isLinux || !_mediaEncoder.SupportsHwaccel("vaapi")) { @@ -987,14 +1020,14 @@ namespace MediaBrowser.Controller.MediaEncoding if (_mediaEncoder.IsVaapiDeviceInteliHD) { - args.Append(GetVaapiDeviceArgs(options.VaapiDevice, "iHD", null, null, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(options.VaapiDevice, "iHD", null, 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(options.VaapiDevice, "i965", null, null, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(options.VaapiDevice, "i965", null, null, null, VaapiAlias)); } var filterDevArgs = string.Empty; @@ -1018,7 +1051,7 @@ namespace MediaBrowser.Controller.MediaEncoding && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) { args.Append(GetDrmDeviceArgs(options.VaapiDevice, DrmAlias)); - args.Append(GetVaapiDeviceArgs(null, null, null, DrmAlias, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(null, null, null, null, DrmAlias, VaapiAlias)); args.Append(GetVulkanDeviceArgs(0, null, DrmAlias, VulkanAlias)); // libplacebo wants an explicitly set vulkan filter device. @@ -1026,7 +1059,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else { - args.Append(GetVaapiDeviceArgs(options.VaapiDevice, null, null, null, VaapiAlias)); + args.Append(GetVaapiDeviceArgs(options.VaapiDevice, null, null, null, null, VaapiAlias)); filterDevArgs = GetFilterHwDeviceArgs(VaapiAlias); if (doOclTonemap) @@ -1045,7 +1078,7 @@ namespace MediaBrowser.Controller.MediaEncoding args.Append(filterDevArgs); } - else if (string.Equals(optHwaccelType, "qsv", StringComparison.OrdinalIgnoreCase)) + else if (optHwaccelType == HardwareAccelerationType.qsv) { if ((!isLinux && !isWindows) || !_mediaEncoder.SupportsHwaccel("qsv")) { @@ -1080,7 +1113,7 @@ namespace MediaBrowser.Controller.MediaEncoding args.Append(filterDevArgs); } - else if (string.Equals(optHwaccelType, "nvenc", StringComparison.OrdinalIgnoreCase)) + else if (optHwaccelType == HardwareAccelerationType.nvenc) { if ((!isLinux && !isWindows) || !IsCudaFullSupported()) { @@ -1099,7 +1132,7 @@ namespace MediaBrowser.Controller.MediaEncoding args.Append(GetCudaDeviceArgs(0, CudaAlias)) .Append(GetFilterHwDeviceArgs(CudaAlias)); } - else if (string.Equals(optHwaccelType, "amf", StringComparison.OrdinalIgnoreCase)) + else if (optHwaccelType == HardwareAccelerationType.amf) { if (!isWindows || !_mediaEncoder.SupportsHwaccel("d3d11va")) { @@ -1124,7 +1157,7 @@ namespace MediaBrowser.Controller.MediaEncoding args.Append(filterDevArgs); } - else if (string.Equals(optHwaccelType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + else if (optHwaccelType == HardwareAccelerationType.videotoolbox) { if (!isMacOS || !_mediaEncoder.SupportsHwaccel("videotoolbox")) { @@ -1141,7 +1174,7 @@ namespace MediaBrowser.Controller.MediaEncoding // videotoolbox hw filter does not require device selection args.Append(GetVideoToolboxDeviceArgs(VideotoolboxAlias)); } - else if (string.Equals(optHwaccelType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + else if (optHwaccelType == HardwareAccelerationType.rkmpp) { if (!isLinux || !_mediaEncoder.SupportsHwaccel("rkmpp")) { @@ -1220,7 +1253,7 @@ namespace MediaBrowser.Controller.MediaEncoding // sub2video for external graphical subtitles if (state.SubtitleStream is not null - && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode + && ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleStream.IsExternal) { @@ -1414,6 +1447,148 @@ namespace MediaBrowser.Controller.MediaEncoding return FormattableString.Invariant($" -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}"); } + private string GetEncoderParam(EncoderPreset? preset, EncoderPreset defaultPreset, EncodingOptions encodingOptions, string videoEncoder, bool isLibX265) + { + var param = string.Empty; + var encoderPreset = preset ?? defaultPreset; + if (string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) || isLibX265) + { + var presetString = encoderPreset switch + { + EncoderPreset.auto => EncoderPreset.veryfast.ToString().ToLowerInvariant(), + _ => encoderPreset.ToString().ToLowerInvariant() + }; + + param += " -preset " + presetString; + + int encodeCrf = encodingOptions.H264Crf; + if (isLibX265) + { + encodeCrf = encodingOptions.H265Crf; + } + + if (encodeCrf >= 0 && encodeCrf <= 51) + { + param += " -crf " + encodeCrf.ToString(CultureInfo.InvariantCulture); + } + else + { + string defaultCrf = "23"; + if (isLibX265) + { + defaultCrf = "28"; + } + + param += " -crf " + defaultCrf; + } + } + else if (string.Equals(videoEncoder, "libsvtav1", StringComparison.OrdinalIgnoreCase)) + { + // Default to use the recommended preset 10. + // Omit presets < 5, which are too slow for on the fly encoding. + // https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Ffmpeg.md + param += encoderPreset switch + { + EncoderPreset.veryslow => " -preset 5", + EncoderPreset.slower => " -preset 6", + EncoderPreset.slow => " -preset 7", + EncoderPreset.medium => " -preset 8", + EncoderPreset.fast => " -preset 9", + EncoderPreset.faster => " -preset 10", + EncoderPreset.veryfast => " -preset 11", + EncoderPreset.superfast => " -preset 12", + EncoderPreset.ultrafast => " -preset 13", + _ => " -preset 10" + }; + } + else if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "av1_vaapi", StringComparison.OrdinalIgnoreCase)) + { + // -compression_level is not reliable on AMD. + if (_mediaEncoder.IsVaapiDeviceInteliHD) + { + param += encoderPreset switch + { + EncoderPreset.veryslow => " -compression_level 1", + EncoderPreset.slower => " -compression_level 2", + EncoderPreset.slow => " -compression_level 3", + EncoderPreset.medium => " -compression_level 4", + EncoderPreset.fast => " -compression_level 5", + EncoderPreset.faster => " -compression_level 6", + EncoderPreset.veryfast => " -compression_level 7", + EncoderPreset.superfast => " -compression_level 7", + EncoderPreset.ultrafast => " -compression_level 7", + _ => string.Empty + }; + } + } + else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase) // hevc (hevc_qsv) + || string.Equals(videoEncoder, "av1_qsv", StringComparison.OrdinalIgnoreCase)) // av1 (av1_qsv) + { + EncoderPreset[] valid_presets = [EncoderPreset.veryslow, EncoderPreset.slower, EncoderPreset.slow, EncoderPreset.medium, EncoderPreset.fast, EncoderPreset.faster, EncoderPreset.veryfast]; + + param += " -preset " + (valid_presets.Contains(encoderPreset) ? encoderPreset : EncoderPreset.veryfast).ToString().ToLowerInvariant(); + } + else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) // hevc (hevc_nvenc) + || string.Equals(videoEncoder, "av1_nvenc", StringComparison.OrdinalIgnoreCase) // av1 (av1_nvenc) + ) + { + param += encoderPreset switch + { + EncoderPreset.veryslow => " -preset p7", + EncoderPreset.slower => " -preset p6", + EncoderPreset.slow => " -preset p5", + EncoderPreset.medium => " -preset p4", + EncoderPreset.fast => " -preset p3", + EncoderPreset.faster => " -preset p2", + _ => " -preset p1" + }; + } + else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) // hevc (hevc_amf) + || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase) // av1 (av1_amf) + ) + { + param += encoderPreset switch + { + EncoderPreset.veryslow => " -quality quality", + EncoderPreset.slower => " -quality quality", + EncoderPreset.slow => " -quality quality", + EncoderPreset.medium => " -quality balanced", + _ => " -quality speed" + }; + + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -header_insertion_mode gop"; + } + + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -gops_per_idr 1"; + } + } + else if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase) // h264 (h264_videotoolbox) + || string.Equals(videoEncoder, "hevc_videotoolbox", StringComparison.OrdinalIgnoreCase) // hevc (hevc_videotoolbox) + ) + { + param += encoderPreset switch + { + EncoderPreset.veryslow => " -prio_speed 0", + EncoderPreset.slower => " -prio_speed 0", + EncoderPreset.slow => " -prio_speed 0", + EncoderPreset.medium => " -prio_speed 0", + _ => " -prio_speed 1" + }; + } + + return param; + } + public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level) { if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel)) @@ -1508,13 +1683,15 @@ namespace MediaBrowser.Controller.MediaEncoding setPtsParam); } - var mediaPath = state.MediaPath ?? string.Empty; + var subtitlePath = _subtitleEncoder.GetSubtitleFilePath( + state.SubtitleStream, + state.MediaSource, + CancellationToken.None).GetAwaiter().GetResult(); return string.Format( CultureInfo.InvariantCulture, - "subtitles=f='{0}':si={1}{2}{3}{4}{5}", - _mediaEncoder.EscapeSubtitleFilterPath(mediaPath), - state.InternalSubtitleStreamOffset.ToString(CultureInfo.InvariantCulture), + "subtitles=f='{0}'{1}{2}{3}{4}", + _mediaEncoder.EscapeSubtitleFilterPath(subtitlePath), alphaParam, sub2videoParam, fontParam, @@ -1534,7 +1711,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (maxrate.HasValue && state.VideoStream is not null) { - var contentRate = state.VideoStream.AverageFrameRate ?? state.VideoStream.RealFrameRate; + var contentRate = state.VideoStream.ReferenceFrameRate; if (contentRate.HasValue && contentRate.Value > maxrate.Value) { @@ -1626,7 +1803,7 @@ namespace MediaBrowser.Controller.MediaEncoding /// Encoding options. /// Default present to use for encoding. /// Video bitrate. - public string GetVideoQualityParam(EncodingJobInfo state, string videoEncoder, EncodingOptions encodingOptions, string defaultPreset) + public string GetVideoQualityParam(EncodingJobInfo state, string videoEncoder, EncodingOptions encodingOptions, EncoderPreset defaultPreset) { var param = string.Empty; @@ -1641,7 +1818,9 @@ namespace MediaBrowser.Controller.MediaEncoding // https://github.com/intel/media-driver/issues/1456 var enableWaFori915Hang = false; - if (string.Equals(encodingOptions.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + var hardwareAccelerationType = encodingOptions.HardwareAccelerationType; + + if (hardwareAccelerationType == HardwareAccelerationType.vaapi) { var isIntelVaapiDriver = _mediaEncoder.IsVaapiDeviceInteliHD || _mediaEncoder.IsVaapiDeviceInteli965; @@ -1654,7 +1833,7 @@ namespace MediaBrowser.Controller.MediaEncoding intelLowPowerHwEncoding = encodingOptions.EnableIntelLowPowerHevcHwEncoder && isIntelVaapiDriver; } } - else if (string.Equals(encodingOptions.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + else if (hardwareAccelerationType == HardwareAccelerationType.qsv) { if (OperatingSystem.IsLinux()) { @@ -1701,204 +1880,10 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -async_depth 1"; } - var isVc1 = string.Equals(state.VideoStream?.Codec, "vc1", StringComparison.OrdinalIgnoreCase); var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase); + var encodingPreset = encodingOptions.EncoderPreset; - if (string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) || isLibX265) - { - if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset)) - { - param += " -preset " + encodingOptions.EncoderPreset; - } - else - { - param += " -preset " + defaultPreset; - } - - int encodeCrf = encodingOptions.H264Crf; - if (isLibX265) - { - encodeCrf = encodingOptions.H265Crf; - } - - if (encodeCrf >= 0 && encodeCrf <= 51) - { - param += " -crf " + encodeCrf.ToString(CultureInfo.InvariantCulture); - } - else - { - string defaultCrf = "23"; - if (isLibX265) - { - defaultCrf = "28"; - } - - param += " -crf " + defaultCrf; - } - } - else if (string.Equals(videoEncoder, "libsvtav1", StringComparison.OrdinalIgnoreCase)) - { - // Default to use the recommended preset 10. - // Omit presets < 5, which are too slow for on the fly encoding. - // https://gitlab.com/AOMediaCodec/SVT-AV1/-/blob/master/Docs/Ffmpeg.md - param += encodingOptions.EncoderPreset switch - { - "veryslow" => " -preset 5", - "slower" => " -preset 6", - "slow" => " -preset 7", - "medium" => " -preset 8", - "fast" => " -preset 9", - "faster" => " -preset 10", - "veryfast" => " -preset 11", - "superfast" => " -preset 12", - "ultrafast" => " -preset 13", - _ => " -preset 10" - }; - } - else if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "av1_vaapi", StringComparison.OrdinalIgnoreCase)) - { - // -compression_level is not reliable on AMD. - if (_mediaEncoder.IsVaapiDeviceInteliHD) - { - param += encodingOptions.EncoderPreset switch - { - "veryslow" => " -compression_level 1", - "slower" => " -compression_level 2", - "slow" => " -compression_level 3", - "medium" => " -compression_level 4", - "fast" => " -compression_level 5", - "faster" => " -compression_level 6", - "veryfast" => " -compression_level 7", - "superfast" => " -compression_level 7", - "ultrafast" => " -compression_level 7", - _ => string.Empty - }; - } - } - else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv) - || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase) // hevc (hevc_qsv) - || string.Equals(videoEncoder, "av1_qsv", StringComparison.OrdinalIgnoreCase)) // av1 (av1_qsv) - { - string[] valid_presets = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" }; - - if (valid_presets.Contains(encodingOptions.EncoderPreset, StringComparison.OrdinalIgnoreCase)) - { - param += " -preset " + encodingOptions.EncoderPreset; - } - else - { - param += " -preset veryfast"; - } - } - else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) // hevc (hevc_nvenc) - || string.Equals(videoEncoder, "av1_nvenc", StringComparison.OrdinalIgnoreCase)) // av1 (av1_nvenc) - { - switch (encodingOptions.EncoderPreset) - { - case "veryslow": - param += " -preset p7"; - break; - - case "slower": - param += " -preset p6"; - break; - - case "slow": - param += " -preset p5"; - break; - - case "medium": - param += " -preset p4"; - break; - - case "fast": - param += " -preset p3"; - break; - - case "faster": - param += " -preset p2"; - break; - - case "veryfast": - case "superfast": - case "ultrafast": - param += " -preset p1"; - break; - - default: - param += " -preset p1"; - break; - } - } - else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) - || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) // hevc (hevc_amf) - || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase)) // av1 (av1_amf) - { - switch (encodingOptions.EncoderPreset) - { - case "veryslow": - case "slower": - case "slow": - param += " -quality quality"; - break; - - case "medium": - param += " -quality balanced"; - break; - - case "fast": - case "faster": - case "veryfast": - case "superfast": - case "ultrafast": - param += " -quality speed"; - break; - - default: - param += " -quality speed"; - break; - } - - if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "av1_amf", StringComparison.OrdinalIgnoreCase)) - { - param += " -header_insertion_mode gop"; - } - - if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) - { - param += " -gops_per_idr 1"; - } - } - else if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase) // h264 (h264_videotoolbox) - || string.Equals(videoEncoder, "hevc_videotoolbox", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_videotoolbox) - { - switch (encodingOptions.EncoderPreset) - { - case "veryslow": - case "slower": - case "slow": - case "medium": - param += " -prio_speed 0"; - break; - - case "fast": - case "faster": - case "veryfast": - case "superfast": - case "ultrafast": - param += " -prio_speed 1"; - break; - - default: - param += " -prio_speed 1"; - break; - } - } - + param += GetEncoderParam(encodingPreset, defaultPreset, encodingOptions, videoEncoder, isLibX265); param += GetVideoBitrateParam(state, videoEncoder); var framerate = GetFramerateParam(state); @@ -1915,7 +1900,26 @@ namespace MediaBrowser.Controller.MediaEncoding } var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault() ?? string.Empty; - profile = WhiteSpaceRegex().Replace(profile, string.Empty); + profile = WhiteSpaceRegex().Replace(profile, string.Empty).ToLowerInvariant(); + + var videoProfiles = Array.Empty(); + if (string.Equals("h264", targetVideoCodec, StringComparison.OrdinalIgnoreCase)) + { + videoProfiles = _videoProfilesH264; + } + else if (string.Equals("hevc", targetVideoCodec, StringComparison.OrdinalIgnoreCase)) + { + videoProfiles = _videoProfilesH265; + } + else if (string.Equals("av1", targetVideoCodec, StringComparison.OrdinalIgnoreCase)) + { + videoProfiles = _videoProfilesAv1; + } + + if (!videoProfiles.Contains(profile, StringComparison.OrdinalIgnoreCase)) + { + profile = string.Empty; + } // We only transcode to HEVC 8-bit for now, force Main Profile. if (profile.Contains("main10", StringComparison.OrdinalIgnoreCase) @@ -2218,7 +2222,7 @@ namespace MediaBrowser.Controller.MediaEncoding var requestedFramerate = request.MaxFramerate ?? request.Framerate; if (requestedFramerate.HasValue) { - var videoFrameRate = videoStream.AverageFrameRate ?? videoStream.RealFrameRate; + var videoFrameRate = videoStream.ReferenceFrameRate; if (!videoFrameRate.HasValue || videoFrameRate.Value > requestedFramerate.Value) { @@ -2415,7 +2419,7 @@ namespace MediaBrowser.Controller.MediaEncoding return 1; } - private static int ScaleBitrate(int bitrate, string inputVideoCodec, string outputVideoCodec) + public static int ScaleBitrate(int bitrate, string inputVideoCodec, string outputVideoCodec) { var inputScaleFactor = GetVideoBitrateScaleFactor(inputVideoCodec); var outputScaleFactor = GetVideoBitrateScaleFactor(outputVideoCodec); @@ -2439,6 +2443,12 @@ namespace MediaBrowser.Controller.MediaEncoding { scaleFactor = Math.Max(scaleFactor, 2); } + else if (bitrate >= 30000000) + { + // Don't scale beyond 30Mbps, it is hardly visually noticeable for most codecs with our prefer speed encoding + // and will cause extremely high bitrate to be used for av1->h264 transcoding that will overload clients and encoders + scaleFactor = 1; + } return Convert.ToInt32(scaleFactor * bitrate); } @@ -2569,7 +2579,7 @@ namespace MediaBrowser.Controller.MediaEncoding } var isCopyingTimestamps = state.CopyTimestamps || state.TranscodingType != TranscodingJobType.Progressive; - if (state.SubtitleStream is not null && state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode && !isCopyingTimestamps) + if (state.SubtitleStream is not null && state.SubtitleStream.IsTextSubtitleStream && ShouldEncodeSubtitle(state) && !isCopyingTimestamps) { var seconds = TimeSpan.FromTicks(state.StartTimeTicks ?? 0).TotalSeconds; @@ -2770,7 +2780,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.AudioStream.IsExternal) { bool hasExternalGraphicsSubs = state.SubtitleStream is not null - && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode + && ShouldEncodeSubtitle(state) && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream; int externalAudioMapIndex = hasExternalGraphicsSubs ? 2 : 1; @@ -3014,6 +3024,8 @@ namespace MediaBrowser.Controller.MediaEncoding public static string GetGraphicalSubPreProcessFilters( int? videoWidth, int? videoHeight, + int? subtitleWidth, + int? subtitleHeight, int? requestedWidth, int? requestedHeight, int? requestedMaxWidth, @@ -3027,16 +3039,37 @@ namespace MediaBrowser.Controller.MediaEncoding requestedMaxWidth, requestedMaxHeight); - if (outWidth.HasValue && outHeight.HasValue) + if (!outWidth.HasValue + || !outHeight.HasValue + || outWidth.Value <= 0 + || outHeight.Value <= 0) { - return string.Format( - CultureInfo.InvariantCulture, - @"scale,scale=-1:{1}:fast_bilinear,crop,pad=max({0}\,iw):max({1}\,ih):(ow-iw)/2:(oh-ih)/2:black@0,crop={0}:{1}", - outWidth.Value, - outHeight.Value); + return string.Empty; } - return string.Empty; + // Automatically add padding based on subtitle input + var filters = @"scale,scale=-1:{1}:fast_bilinear,crop,pad=max({0}\,iw):max({1}\,ih):(ow-iw)/2:(oh-ih)/2:black@0,crop={0}:{1}"; + + if (subtitleWidth.HasValue + && subtitleHeight.HasValue + && subtitleWidth.Value > 0 + && subtitleHeight.Value > 0) + { + var videoDar = (double)outWidth.Value / outHeight.Value; + var subtitleDar = (double)subtitleWidth.Value / subtitleHeight.Value; + + // No need to add padding when DAR is the same -> 1080p PGSSUB on 2160p video + if (Math.Abs(videoDar - subtitleDar) < 0.01f) + { + filters = @"scale,scale={0}:{1}:fast_bilinear"; + } + } + + return string.Format( + CultureInfo.InvariantCulture, + filters, + outWidth.Value, + outHeight.Value); } public static string GetAlphaSrcFilter( @@ -3234,21 +3267,20 @@ namespace MediaBrowser.Controller.MediaEncoding public static string GetSwDeinterlaceFilter(EncodingJobInfo state, EncodingOptions options) { - var doubleRateDeint = options.DeinterlaceDoubleRate && state.VideoStream?.AverageFrameRate <= 30; + var doubleRateDeint = options.DeinterlaceDoubleRate && state.VideoStream?.ReferenceFrameRate <= 30; return string.Format( CultureInfo.InvariantCulture, "{0}={1}:-1:0", - string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase) ? "bwdif" : "yadif", + options.DeinterlaceMethod.ToString().ToLowerInvariant(), doubleRateDeint ? "1" : "0"); } public string GetHwDeinterlaceFilter(EncodingJobInfo state, EncodingOptions options, string hwDeintSuffix) { - var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.AverageFrameRate ?? 60) <= 30; + var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30; if (hwDeintSuffix.Contains("cuda", StringComparison.OrdinalIgnoreCase)) { - var useBwdif = string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase) - && _mediaEncoder.SupportsFilter("bwdif_cuda"); + var useBwdif = options.DeinterlaceMethod == DeinterlaceMethod.bwdif && _mediaEncoder.SupportsFilter("bwdif_cuda"); return string.Format( CultureInfo.InvariantCulture, @@ -3272,16 +3304,19 @@ namespace MediaBrowser.Controller.MediaEncoding if (hwDeintSuffix.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase)) { + var useBwdif = options.DeinterlaceMethod == DeinterlaceMethod.bwdif && _mediaEncoder.SupportsFilter("bwdif_videotoolbox"); + return string.Format( CultureInfo.InvariantCulture, - "yadif_videotoolbox={0}:-1:0", + "{0}_videotoolbox={1}:-1:0", + useBwdif ? "bwdif" : "yadif", doubleRateDeint ? "1" : "0"); } return string.Empty; } - public string GetHwTonemapFilter(EncodingOptions options, string hwTonemapSuffix, string videoFormat) + private string GetHwTonemapFilter(EncodingOptions options, string hwTonemapSuffix, string videoFormat, bool forceFullRange) { if (string.IsNullOrEmpty(hwTonemapSuffix)) { @@ -3289,7 +3324,10 @@ namespace MediaBrowser.Controller.MediaEncoding } var args = string.Empty; - var algorithm = options.TonemappingAlgorithm; + var algorithm = options.TonemappingAlgorithm.ToString().ToLowerInvariant(); + var mode = options.TonemappingMode.ToString().ToLowerInvariant(); + var range = forceFullRange ? TonemappingRange.pc : options.TonemappingRange; + var rangeString = range.ToString().ToLowerInvariant(); if (string.Equals(hwTonemapSuffix, "vaapi", StringComparison.OrdinalIgnoreCase)) { @@ -3324,10 +3362,10 @@ namespace MediaBrowser.Controller.MediaEncoding args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709:tonemap={2}:peak={3}:desat={4}"; var useLegacyTonemapModes = _mediaEncoder.EncoderVersion >= _minFFmpegOclCuTonemapMode - && _legacyTonemapModes.Contains(options.TonemappingMode, StringComparison.OrdinalIgnoreCase); + && _legacyTonemapModes.Contains(options.TonemappingMode); var useAdvancedTonemapModes = _mediaEncoder.EncoderVersion >= _minFFmpegAdvancedTonemapMode - && _advancedTonemapModes.Contains(options.TonemappingMode, StringComparison.OrdinalIgnoreCase); + && _advancedTonemapModes.Contains(options.TonemappingMode); if (useLegacyTonemapModes || useAdvancedTonemapModes) { @@ -3339,8 +3377,7 @@ namespace MediaBrowser.Controller.MediaEncoding args += ":param={6}"; } - if (string.Equals(options.TonemappingRange, "tv", StringComparison.OrdinalIgnoreCase) - || string.Equals(options.TonemappingRange, "pc", StringComparison.OrdinalIgnoreCase)) + if (range == TonemappingRange.tv || range == TonemappingRange.pc) { args += ":range={7}"; } @@ -3354,12 +3391,12 @@ namespace MediaBrowser.Controller.MediaEncoding algorithm, options.TonemappingPeak, options.TonemappingDesat, - options.TonemappingMode, + mode, options.TonemappingParam, - options.TonemappingRange); + rangeString); } - public string GetLibplaceboFilter( + private string GetLibplaceboFilter( EncodingOptions options, string videoFormat, bool doTonemap, @@ -3368,7 +3405,8 @@ namespace MediaBrowser.Controller.MediaEncoding int? requestedWidth, int? requestedHeight, int? requestedMaxWidth, - int? requestedMaxHeight) + int? requestedMaxHeight, + bool forceFullRange) { var (outWidth, outHeight) = GetFixedOutputSize( videoWidth, @@ -3391,24 +3429,24 @@ namespace MediaBrowser.Controller.MediaEncoding if (doTonemap) { var algorithm = options.TonemappingAlgorithm; + var algorithmString = "clip"; var mode = options.TonemappingMode; - var range = options.TonemappingRange; + var range = forceFullRange ? TonemappingRange.pc : options.TonemappingRange; - if (string.Equals(algorithm, "bt2390", StringComparison.OrdinalIgnoreCase)) + if (algorithm == TonemappingAlgorithm.bt2390) { - algorithm = "bt.2390"; + algorithmString = "bt.2390"; } - else if (string.Equals(algorithm, "none", StringComparison.OrdinalIgnoreCase)) + else if (algorithm != TonemappingAlgorithm.none) { - algorithm = "clip"; + algorithmString = algorithm.ToString().ToLowerInvariant(); } - tonemapArg = ":tonemapping=" + algorithm + ":peak_detect=0:color_primaries=bt709:color_trc=bt709:colorspace=bt709"; + tonemapArg = $":tonemapping={algorithmString}:peak_detect=0:color_primaries=bt709:color_trc=bt709:colorspace=bt709"; - if (string.Equals(range, "tv", StringComparison.OrdinalIgnoreCase) - || string.Equals(range, "pc", StringComparison.OrdinalIgnoreCase)) + if (range == TonemappingRange.tv || range == TonemappingRange.pc) { - tonemapArg += ":range=" + range; + tonemapArg += ":range=" + range.ToString().ToLowerInvariant(); } } @@ -3463,7 +3501,7 @@ namespace MediaBrowser.Controller.MediaEncoding var doToneMap = IsSwTonemapAvailable(state, options); var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI; - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; @@ -3512,8 +3550,8 @@ namespace MediaBrowser.Controller.MediaEncoding tonemapArgs += $":param={options.TonemappingParam}"; } - if (string.Equals(options.TonemappingRange, "tv", StringComparison.OrdinalIgnoreCase) - || string.Equals(options.TonemappingRange, "pc", StringComparison.OrdinalIgnoreCase)) + var range = options.TonemappingRange; + if (range == TonemappingRange.tv || range == TonemappingRange.pc) { tonemapArgs += $":range={options.TonemappingRange}"; } @@ -3537,7 +3575,9 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } @@ -3557,7 +3597,7 @@ namespace MediaBrowser.Controller.MediaEncoding EncodingOptions options, string vidEncoder) { - if (!string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + if (options.HardwareAccelerationType != HardwareAccelerationType.nvenc) { return (null, null, null); } @@ -3596,20 +3636,23 @@ namespace MediaBrowser.Controller.MediaEncoding var isNvencEncoder = vidEncoder.Contains("nvenc", StringComparison.OrdinalIgnoreCase); var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isNvencEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isCuInCuOut = isNvDecoder && isNvencEncoder; - var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.AverageFrameRate ?? 60) <= 30; + var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30; var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doDeintH2645 = doDeintH264 || doDeintHevc; var doCuTonemap = IsHwTonemapAvailable(state, options); - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); @@ -3662,7 +3705,8 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add($"transpose_cuda=dir={tranposeDir}"); } - var outFormat = doCuTonemap ? string.Empty : "yuv420p"; + var isRext = IsVideoStreamHevcRext(state); + var outFormat = doCuTonemap ? (isRext ? "p010" : string.Empty) : "yuv420p"; var hwScaleFilter = GetHwScaleFilter("scale", "cuda", outFormat, false, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); // hw scale mainFilters.Add(hwScaleFilter); @@ -3671,7 +3715,7 @@ namespace MediaBrowser.Controller.MediaEncoding // hw tonemap if (doCuTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "cuda", "yuv420p"); + var tonemapFilter = GetHwTonemapFilter(options, "cuda", "yuv420p", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -3713,7 +3757,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); subFilters.Add("format=yuva420p"); } @@ -3738,7 +3782,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } @@ -3759,7 +3803,7 @@ namespace MediaBrowser.Controller.MediaEncoding EncodingOptions options, string vidEncoder) { - if (!string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + if (options.HardwareAccelerationType != HardwareAccelerationType.amf) { return (null, null, null); } @@ -3800,6 +3844,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isAmfEncoder = vidEncoder.Contains("amf", StringComparison.OrdinalIgnoreCase); var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isAmfEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder; var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); @@ -3807,12 +3852,14 @@ namespace MediaBrowser.Controller.MediaEncoding var doDeintH2645 = doDeintH264 || doDeintHevc; var doOclTonemap = IsHwTonemapAvailable(state, options); - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); @@ -3877,7 +3924,7 @@ namespace MediaBrowser.Controller.MediaEncoding // hw tonemap if (doOclTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -3927,7 +3974,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); subFilters.Add("format=yuva420p"); } @@ -3954,7 +4001,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } @@ -3975,7 +4022,7 @@ namespace MediaBrowser.Controller.MediaEncoding EncodingOptions options, string vidEncoder) { - if (!string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + if (options.HardwareAccelerationType != HardwareAccelerationType.qsv) { return (null, null, null); } @@ -4036,6 +4083,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isHwDecoder = isD3d11vaDecoder || isQsvDecoder; var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isQsvEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isQsvInQsvOut = isHwDecoder && isQsvEncoder; var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); @@ -4045,12 +4093,14 @@ namespace MediaBrowser.Controller.MediaEncoding var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options); var doTonemap = doVppTonemap || doOclTonemap; - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); @@ -4076,6 +4126,12 @@ namespace MediaBrowser.Controller.MediaEncoding var outFormat = doOclTonemap ? "yuv420p10le" : (hasGraphicalSubs ? "yuv420p" : "nv12"); var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + if (isMjpegEncoder && !doOclTonemap) + { + // sw decoder + hw mjpeg encoder + swScaleFilter = string.IsNullOrEmpty(swScaleFilter) ? "scale=out_range=pc" : $"{swScaleFilter}:out_range=pc"; + } + // sw scale mainFilters.Add(swScaleFilter); mainFilters.Add($"format={outFormat}"); @@ -4090,6 +4146,12 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (isD3d11vaDecoder || isQsvDecoder) { + var isRext = IsVideoStreamHevcRext(state); + var twoPassVppTonemap = isRext; + var doVppFullRangeOut = isMjpegEncoder + && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppOutRangeOption; + var doVppScaleModeHq = isMjpegEncoder + && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppScaleModeOption; var doVppProcamp = false; var procampParams = string.Empty; if (doVppTonemap) @@ -4099,34 +4161,39 @@ namespace MediaBrowser.Controller.MediaEncoding && options.VppTonemappingBrightness <= 100) { procampParams += $":brightness={options.VppTonemappingBrightness}"; - doVppProcamp = true; + twoPassVppTonemap = doVppProcamp = true; } if (options.VppTonemappingContrast > 1 && options.VppTonemappingContrast <= 10) { procampParams += $":contrast={options.VppTonemappingContrast}"; - doVppProcamp = true; + twoPassVppTonemap = doVppProcamp = true; } procampParams += doVppProcamp ? ":procamp=1:async_depth=2" : string.Empty; } - var outFormat = doOclTonemap ? (doVppTranspose ? "p010" : string.Empty) : "nv12"; - outFormat = (doVppTonemap && doVppProcamp) ? "p010" : outFormat; + var outFormat = doOclTonemap ? ((doVppTranspose || isRext) ? "p010" : string.Empty) : "nv12"; + outFormat = twoPassVppTonemap ? "p010" : outFormat; var swapOutputWandH = doVppTranspose && swapWAndH; - var hwScalePrefix = (doVppTranspose || doVppTonemap) ? "vpp" : "scale"; - var hwScaleFilter = GetHwScaleFilter(hwScalePrefix, "qsv", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var hwScaleFilter = GetHwScaleFilter("vpp", "qsv", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); if (!string.IsNullOrEmpty(hwScaleFilter) && doVppTranspose) { hwScaleFilter += $":transpose={tranposeDir}"; } + if (!string.IsNullOrEmpty(hwScaleFilter) && isMjpegEncoder) + { + hwScaleFilter += (doVppFullRangeOut && !doOclTonemap) ? ":out_range=pc" : string.Empty; + hwScaleFilter += doVppScaleModeHq ? ":scale_mode=hq" : string.Empty; + } + if (!string.IsNullOrEmpty(hwScaleFilter) && doVppTonemap) { - hwScaleFilter += doVppProcamp ? procampParams : ":tonemap=1"; + hwScaleFilter += doVppProcamp ? procampParams : (twoPassVppTonemap ? string.Empty : ":tonemap=1"); } if (isD3d11vaDecoder) @@ -4150,7 +4217,7 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add(hwScaleFilter); // hw tonemap(w/ procamp) - if (doVppTonemap && doVppProcamp) + if (doVppTonemap && twoPassVppTonemap) { mainFilters.Add("vpp_qsv=tonemap=1:format=nv12:async_depth=2"); } @@ -4171,7 +4238,7 @@ namespace MediaBrowser.Controller.MediaEncoding // hw tonemap if (doOclTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -4223,7 +4290,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (hasGraphicalSubs) { // overlay_qsv can handle overlay scaling, setup a smaller height to reduce transfer overhead - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, 1080); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, 1080); subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } @@ -4259,7 +4326,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } @@ -4288,6 +4355,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isHwDecoder = isVaapiDecoder || isQsvDecoder; var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isQsvEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isQsvInQsvOut = isHwDecoder && isQsvEncoder; var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); @@ -4297,12 +4365,14 @@ namespace MediaBrowser.Controller.MediaEncoding var doTonemap = doVaVppTonemap || doOclTonemap; var doDeintH2645 = doDeintH264 || doDeintHevc; - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); @@ -4328,6 +4398,12 @@ namespace MediaBrowser.Controller.MediaEncoding var outFormat = doOclTonemap ? "yuv420p10le" : (hasGraphicalSubs ? "yuv420p" : "nv12"); var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + if (isMjpegEncoder && !doOclTonemap) + { + // sw decoder + hw mjpeg encoder + swScaleFilter = string.IsNullOrEmpty(swScaleFilter) ? "scale=out_range=pc" : $"{swScaleFilter}:out_range=pc"; + } + // sw scale mainFilters.Add(swScaleFilter); mainFilters.Add($"format={outFormat}"); @@ -4343,6 +4419,11 @@ namespace MediaBrowser.Controller.MediaEncoding else if (isVaapiDecoder || isQsvDecoder) { var hwFilterSuffix = isVaapiDecoder ? "vaapi" : "qsv"; + var isRext = IsVideoStreamHevcRext(state); + var doVppFullRangeOut = isMjpegEncoder + && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppOutRangeOption; + var doVppScaleModeHq = isMjpegEncoder + && _mediaEncoder.EncoderVersion >= _minFFmpegQsvVppScaleModeOption; // INPUT vaapi/qsv surface(vram) // hw deint @@ -4358,9 +4439,9 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add($"transpose_vaapi=dir={tranposeDir}"); } - var outFormat = doOclTonemap ? ((isQsvDecoder && doVppTranspose) ? "p010" : string.Empty) : "nv12"; + var outFormat = doTonemap ? (((isQsvDecoder && doVppTranspose) || isRext) ? "p010" : string.Empty) : "nv12"; var swapOutputWandH = isQsvDecoder && doVppTranspose && swapWAndH; - var hwScalePrefix = (isQsvDecoder && doVppTranspose) ? "vpp" : "scale"; + var hwScalePrefix = isQsvDecoder ? "vpp" : "scale"; var hwScaleFilter = GetHwScaleFilter(hwScalePrefix, hwFilterSuffix, outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); if (!string.IsNullOrEmpty(hwScaleFilter) && isQsvDecoder && doVppTranspose) @@ -4368,6 +4449,12 @@ namespace MediaBrowser.Controller.MediaEncoding hwScaleFilter += $":transpose={tranposeDir}"; } + if (!string.IsNullOrEmpty(hwScaleFilter) && isMjpegEncoder) + { + hwScaleFilter += ((isQsvDecoder && !doVppFullRangeOut) || doOclTonemap) ? string.Empty : ":out_range=pc"; + hwScaleFilter += isQsvDecoder ? (doVppScaleModeHq ? ":scale_mode=hq" : string.Empty) : ":mode=hq"; + } + // allocate extra pool sizes for vaapi vpp scale if (!string.IsNullOrEmpty(hwScaleFilter) && isVaapiDecoder) { @@ -4388,7 +4475,7 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add("format=vaapi"); } - var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); if (isQsvDecoder) @@ -4408,7 +4495,7 @@ namespace MediaBrowser.Controller.MediaEncoding // ocl tonemap if (doOclTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -4469,7 +4556,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (hasGraphicalSubs) { // overlay_qsv can handle overlay scaling, setup a smaller height to reduce transfer overhead - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, 1080); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, 1080); subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } @@ -4504,7 +4591,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } @@ -4525,7 +4612,7 @@ namespace MediaBrowser.Controller.MediaEncoding EncodingOptions options, string vidEncoder) { - if (!string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + if (options.HardwareAccelerationType != HardwareAccelerationType.vaapi) { return (null, null, null); } @@ -4599,6 +4686,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isVaapiEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); @@ -4608,12 +4696,14 @@ namespace MediaBrowser.Controller.MediaEncoding var doTonemap = doVaVppTonemap || doOclTonemap; var doDeintH2645 = doDeintH264 || doDeintHevc; - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); @@ -4639,6 +4729,12 @@ namespace MediaBrowser.Controller.MediaEncoding var outFormat = doOclTonemap ? "yuv420p10le" : "nv12"; var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + if (isMjpegEncoder && !doOclTonemap) + { + // sw decoder + hw mjpeg encoder + swScaleFilter = string.IsNullOrEmpty(swScaleFilter) ? "scale=out_range=pc" : $"{swScaleFilter}:out_range=pc"; + } + // sw scale mainFilters.Add(swScaleFilter); mainFilters.Add($"format={outFormat}"); @@ -4653,6 +4749,8 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (isVaapiDecoder) { + var isRext = IsVideoStreamHevcRext(state); + // INPUT vaapi surface(vram) // hw deint if (doDeintH2645) @@ -4667,9 +4765,15 @@ namespace MediaBrowser.Controller.MediaEncoding mainFilters.Add($"transpose_vaapi=dir={tranposeDir}"); } - var outFormat = doTonemap ? string.Empty : "nv12"; + var outFormat = doTonemap ? (isRext ? "p010" : string.Empty) : "nv12"; var hwScaleFilter = GetHwScaleFilter("scale", "vaapi", outFormat, false, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + if (!string.IsNullOrEmpty(hwScaleFilter) && isMjpegEncoder) + { + hwScaleFilter += doOclTonemap ? string.Empty : ":out_range=pc"; + hwScaleFilter += ":mode=hq"; + } + // allocate extra pool sizes for vaapi vpp if (!string.IsNullOrEmpty(hwScaleFilter)) { @@ -4683,7 +4787,7 @@ namespace MediaBrowser.Controller.MediaEncoding // vaapi vpp tonemap if (doVaVppTonemap && isVaapiDecoder) { - var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -4696,7 +4800,7 @@ namespace MediaBrowser.Controller.MediaEncoding // ocl tonemap if (doOclTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -4755,7 +4859,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (hasGraphicalSubs) { // overlay_vaapi can handle overlay scaling, setup a smaller height to reduce transfer overhead - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, 1080); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, 1080); subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } @@ -4788,7 +4892,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); @@ -4820,13 +4924,14 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isVaapiEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doVkTonemap = IsVulkanHwTonemapAvailable(state, options); var doDeintH2645 = doDeintH264 || doDeintHevc; - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs @@ -4915,6 +5020,12 @@ namespace MediaBrowser.Controller.MediaEncoding // hw scale var hwScaleFilter = GetHwScaleFilter("scale", "vaapi", "nv12", false, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + if (!string.IsNullOrEmpty(hwScaleFilter) && isMjpegEncoder && !doVkTonemap) + { + hwScaleFilter += ":out_range=pc:mode=hq"; + } + mainFilters.Add(hwScaleFilter); } } @@ -4935,7 +5046,7 @@ namespace MediaBrowser.Controller.MediaEncoding // vk libplacebo if (doVkTonemap || hasSubs) { - var libplaceboFilter = GetLibplaceboFilter(options, "bgra", doVkTonemap, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var libplaceboFilter = GetLibplaceboFilter(options, "bgra", doVkTonemap, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH, isMjpegEncoder); mainFilters.Add(libplaceboFilter); mainFilters.Add("format=vulkan"); } @@ -4980,7 +5091,9 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } @@ -5048,6 +5161,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !isVaapiEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965; var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd; @@ -5057,7 +5171,7 @@ namespace MediaBrowser.Controller.MediaEncoding var doDeintH2645 = doDeintH264 || doDeintHevc; var doOclTonemap = IsHwTonemapAvailable(state, options); - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; @@ -5084,6 +5198,12 @@ namespace MediaBrowser.Controller.MediaEncoding outFormat = doOclTonemap ? "yuv420p10le" : "nv12"; var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + if (isMjpegEncoder && !doOclTonemap) + { + // sw decoder + hw mjpeg encoder + swScaleFilter = string.IsNullOrEmpty(swScaleFilter) ? "scale=out_range=pc" : $"{swScaleFilter}:out_range=pc"; + } + // sw scale mainFilters.Add(swScaleFilter); mainFilters.Add("format=" + outFormat); @@ -5109,6 +5229,12 @@ namespace MediaBrowser.Controller.MediaEncoding outFormat = doOclTonemap ? string.Empty : "nv12"; var hwScaleFilter = GetHwScaleFilter("scale", "vaapi", outFormat, false, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + if (!string.IsNullOrEmpty(hwScaleFilter) && isMjpegEncoder) + { + hwScaleFilter += doOclTonemap ? string.Empty : ":out_range=pc"; + hwScaleFilter += ":mode=hq"; + } + // allocate extra pool sizes for vaapi vpp if (!string.IsNullOrEmpty(hwScaleFilter)) { @@ -5137,7 +5263,7 @@ namespace MediaBrowser.Controller.MediaEncoding // ocl tonemap if (doOclTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -5203,7 +5329,9 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); @@ -5229,7 +5357,7 @@ namespace MediaBrowser.Controller.MediaEncoding EncodingOptions options, string vidEncoder) { - if (!string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + if (options.HardwareAccelerationType != HardwareAccelerationType.videotoolbox) { return (null, null, null); } @@ -5261,6 +5389,7 @@ namespace MediaBrowser.Controller.MediaEncoding { var isVtEncoder = vidEncoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase); var isVtDecoder = vidDecoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase); + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var inW = state.VideoStream?.Width; var inH = state.VideoStream?.Height; @@ -5303,7 +5432,7 @@ namespace MediaBrowser.Controller.MediaEncoding var hwScaleFilter = GetHwScaleFilter("scale", "vt", scaleFormat, false, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs @@ -5342,7 +5471,7 @@ namespace MediaBrowser.Controller.MediaEncoding // Metal tonemap if (doMetalTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "videotoolbox", "nv12"); + var tonemapFilter = GetHwTonemapFilter(options, "videotoolbox", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -5354,7 +5483,9 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } @@ -5418,7 +5549,7 @@ namespace MediaBrowser.Controller.MediaEncoding EncodingOptions options, string vidEncoder) { - if (!string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + if (options.HardwareAccelerationType != HardwareAccelerationType.rkmpp) { return (null, null, null); } @@ -5463,19 +5594,25 @@ namespace MediaBrowser.Controller.MediaEncoding var isRkmppEncoder = vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase); var isSwDecoder = !isRkmppDecoder; var isSwEncoder = !isRkmppEncoder; + var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isDrmInDrmOut = isRkmppDecoder && isRkmppEncoder; + var isEncoderSupportAfbc = isRkmppEncoder + && (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase) + || vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase)); var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doDeintH2645 = doDeintH264 || doDeintHevc; var doOclTonemap = IsHwTonemapAvailable(state, options); - var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream != null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; var hasAssSubs = hasSubs && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + var subW = state.SubtitleStream?.Width; + var subH = state.SubtitleStream?.Height; var rotation = state.VideoStream?.Rotation ?? 0; var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state); @@ -5501,6 +5638,12 @@ namespace MediaBrowser.Controller.MediaEncoding var outFormat = doOclTonemap ? "yuv420p10le" : (hasGraphicalSubs ? "yuv420p" : "nv12"); var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + if (isMjpegEncoder && !doOclTonemap) + { + // sw decoder + hw mjpeg encoder + swScaleFilter = string.IsNullOrEmpty(swScaleFilter) ? "scale=out_range=pc" : $"{swScaleFilter}:out_range=pc"; + } + if (!string.IsNullOrEmpty(swScaleFilter)) { swScaleFilter += ":flags=fast_bilinear"; @@ -5522,18 +5665,26 @@ namespace MediaBrowser.Controller.MediaEncoding { // INPUT rkmpp/drm surface(gem/dma-heap) - var isFullAfbcPipeline = isDrmInDrmOut && !doOclTonemap; + var isFullAfbcPipeline = isEncoderSupportAfbc && isDrmInDrmOut && !doOclTonemap; var swapOutputWandH = doRkVppTranspose && swapWAndH; - var outFormat = doOclTonemap ? "p010" : "nv12"; - var hwScalePrefix = doRkVppTranspose ? "vpp" : "scale"; - var hwScaleFilter = GetHwScaleFilter(hwScalePrefix, "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); - var hwScaleFilter2 = GetHwScaleFilter(hwScalePrefix, "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var outFormat = doOclTonemap ? "p010" : (isMjpegEncoder ? "bgra" : "nv12"); // RGA only support full range in rgb fmts + var hwScaleFilter = GetHwScaleFilter("vpp", "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var doScaling = GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); if (!hasSubs || doRkVppTranspose || !isFullAfbcPipeline - || !string.IsNullOrEmpty(hwScaleFilter2)) + || !string.IsNullOrEmpty(doScaling)) { + // RGA3 hardware only support (1/8 ~ 8) scaling in each blit operation, + // but in Trickplay there's a case: (3840/320 == 12), enable 2pass for it + if (!string.IsNullOrEmpty(doScaling) + && !IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f)) + { + var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={outFormat}:afbc=1"; + mainFilters.Add(hwScaleFilterFirstPass); + } + if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose) { hwScaleFilter += $":transpose={tranposeDir}"; @@ -5553,19 +5704,13 @@ namespace MediaBrowser.Controller.MediaEncoding if (doOclTonemap && isRkmppDecoder) { // map from rkmpp/drm to opencl via drm-opencl interop. - mainFilters.Add("hwmap=derive_device=opencl:mode=read"); + mainFilters.Add("hwmap=derive_device=opencl"); } // ocl tonemap if (doOclTonemap) { - var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12"); - // enable tradeoffs for performance - if (!string.IsNullOrEmpty(tonemapFilter)) - { - tonemapFilter += ":tradeoff=1"; - } - + var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12", isMjpegEncoder); mainFilters.Add(tonemapFilter); } @@ -5602,7 +5747,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // OUTPUT drm(nv12) surface(gem/dma-heap) // reverse-mapping via drm-opencl interop. - mainFilters.Add("hwmap=derive_device=rkmpp:mode=write:reverse=1"); + mainFilters.Add("hwmap=derive_device=rkmpp:reverse=1"); mainFilters.Add("format=drm_prime"); } } @@ -5616,7 +5761,7 @@ namespace MediaBrowser.Controller.MediaEncoding { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); subFilters.Add("format=bgra"); } @@ -5636,14 +5781,20 @@ namespace MediaBrowser.Controller.MediaEncoding subFilters.Add("hwupload=derive_device=rkmpp"); // try enabling AFBC to save DDR bandwidth - overlayFilters.Add("overlay_rkrga=eof_action=pass:repeatlast=0:format=nv12:afbc=1"); + var hwOverlayFilter = "overlay_rkrga=eof_action=pass:repeatlast=0:format=nv12"; + if (isEncoderSupportAfbc) + { + hwOverlayFilter += ":afbc=1"; + } + + overlayFilters.Add(hwOverlayFilter); } } else if (memoryOutput) { if (hasGraphicalSubs) { - var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); + var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH); subFilters.Add(subPreProcFilters); overlayFilters.Add("overlay=eof_action=pass:repeatlast=0"); } @@ -5670,7 +5821,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; @@ -5678,38 +5829,20 @@ namespace MediaBrowser.Controller.MediaEncoding List subFilters; List overlayFilters; - if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + (mainFilters, subFilters, overlayFilters) = options.HardwareAccelerationType switch { - (mainFilters, subFilters, overlayFilters) = GetVaapiVidFilterChain(state, options, outputVideoCodec); - } - else if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) - { - (mainFilters, subFilters, overlayFilters) = GetIntelVidFilterChain(state, options, outputVideoCodec); - } - else if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) - { - (mainFilters, subFilters, overlayFilters) = GetNvidiaVidFilterChain(state, options, outputVideoCodec); - } - else if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) - { - (mainFilters, subFilters, overlayFilters) = GetAmdVidFilterChain(state, options, outputVideoCodec); - } - else if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) - { - (mainFilters, subFilters, overlayFilters) = GetAppleVidFilterChain(state, options, outputVideoCodec); - } - else if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) - { - (mainFilters, subFilters, overlayFilters) = GetRkmppVidFilterChain(state, options, outputVideoCodec); - } - else - { - (mainFilters, subFilters, overlayFilters) = GetSwVidFilterChain(state, options, outputVideoCodec); - } + HardwareAccelerationType.vaapi => GetVaapiVidFilterChain(state, options, outputVideoCodec), + HardwareAccelerationType.amf => GetAmdVidFilterChain(state, options, outputVideoCodec), + HardwareAccelerationType.qsv => GetIntelVidFilterChain(state, options, outputVideoCodec), + HardwareAccelerationType.nvenc => GetNvidiaVidFilterChain(state, options, outputVideoCodec), + HardwareAccelerationType.videotoolbox => GetAppleVidFilterChain(state, options, outputVideoCodec), + HardwareAccelerationType.rkmpp => GetRkmppVidFilterChain(state, options, outputVideoCodec), + _ => GetSwVidFilterChain(state, options, outputVideoCodec), + }; - mainFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); - subFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); - overlayFilters?.RemoveAll(filter => string.IsNullOrEmpty(filter)); + mainFilters?.RemoveAll(string.IsNullOrEmpty); + subFilters?.RemoveAll(string.IsNullOrEmpty); + overlayFilters?.RemoveAll(string.IsNullOrEmpty); var framerate = GetFramerateParam(state); if (framerate.HasValue) @@ -5889,7 +6022,9 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } - if (!string.IsNullOrEmpty(videoStream.Codec) && !string.IsNullOrEmpty(options.HardwareAccelerationType)) + var hardwareAccelerationType = options.HardwareAccelerationType; + + if (!string.IsNullOrEmpty(videoStream.Codec) && hardwareAccelerationType != HardwareAccelerationType.none) { var bitDepth = GetVideoColorBitDepth(state); @@ -5901,10 +6036,10 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))) { // RKMPP has H.264 Hi10P decoder - bool hasHardwareHi10P = string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase); + bool hasHardwareHi10P = hardwareAccelerationType == HardwareAccelerationType.rkmpp; // VideoToolbox on Apple Silicon has H.264 Hi10P mode enabled after macOS 14.6 - if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + if (hardwareAccelerationType == HardwareAccelerationType.videotoolbox) { var ver = Environment.OSVersion.Version; var arch = RuntimeInformation.OSArchitecture; @@ -5921,50 +6056,23 @@ namespace MediaBrowser.Controller.MediaEncoding } } - if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + var decoder = hardwareAccelerationType switch { - return GetQsvHwVidDecoder(state, options, videoStream, bitDepth); - } + HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth), + HardwareAccelerationType.amf => GetAmfVidDecoder(state, options, videoStream, bitDepth), + HardwareAccelerationType.qsv => GetQsvHwVidDecoder(state, options, videoStream, bitDepth), + HardwareAccelerationType.nvenc => GetNvdecVidDecoder(state, options, videoStream, bitDepth), + HardwareAccelerationType.videotoolbox => GetVideotoolboxVidDecoder(state, options, videoStream, bitDepth), + HardwareAccelerationType.rkmpp => GetRkmppVidDecoder(state, options, videoStream, bitDepth), + _ => string.Empty + }; - if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(decoder)) { - return GetNvdecVidDecoder(state, options, videoStream, bitDepth); - } - - if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) - { - return GetAmfVidDecoder(state, options, videoStream, bitDepth); - } - - if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) - { - return GetVaapiVidDecoder(state, options, videoStream, bitDepth); - } - - if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) - { - return GetVideotoolboxVidDecoder(state, options, videoStream, bitDepth); - } - - if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) - { - return GetRkmppVidDecoder(state, options, videoStream, bitDepth); + return decoder; } } - var whichCodec = videoStream.Codec; - if (string.Equals(whichCodec, "avc", StringComparison.OrdinalIgnoreCase)) - { - whichCodec = "h264"; - } - else if (string.Equals(whichCodec, "h265", StringComparison.OrdinalIgnoreCase)) - { - whichCodec = "hevc"; - } - - // Avoid a second attempt if no hardware acceleration is being used - options.HardwareDecodingCodecs = Array.FindAll(options.HardwareDecodingCodecs, val => !string.Equals(val, whichCodec, StringComparison.OrdinalIgnoreCase)); - // leave blank so ffmpeg will decide return null; } @@ -5988,7 +6096,11 @@ namespace MediaBrowser.Controller.MediaEncoding var decoderName = decoderPrefix + '_' + decoderSuffix; var isCodecAvailable = _mediaEncoder.SupportsDecoder(decoderName) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase); - if (bitDepth == 10 && isCodecAvailable) + + // VideoToolbox decoders have built-in SW fallback + if (bitDepth == 10 + && isCodecAvailable + && (options.HardwareAccelerationType != HardwareAccelerationType.videotoolbox)) { if (string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase) && options.HardwareDecodingCodecs.Contains("hevc", StringComparison.OrdinalIgnoreCase) @@ -6044,6 +6156,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isVideotoolboxSupported = isMacOS && _mediaEncoder.SupportsHwaccel("videotoolbox"); var isRkmppSupported = isLinux && IsRkmppFullSupported(); var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase); + var hardwareAccelerationType = options.HardwareAccelerationType; var ffmpegVersion = _mediaEncoder.EncoderVersion; @@ -6063,17 +6176,40 @@ namespace MediaBrowser.Controller.MediaEncoding && ffmpegVersion >= _minFFmpegDisplayRotationOption; var stripRotationDataArgs = stripRotationData ? " -display_rotation 0" : string.Empty; - if (bitDepth == 10 && isCodecAvailable) + // VideoToolbox decoders have built-in SW fallback + if (isCodecAvailable + && (options.HardwareAccelerationType != HardwareAccelerationType.videotoolbox)) { if (string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase) - && options.HardwareDecodingCodecs.Contains("hevc", StringComparison.OrdinalIgnoreCase) - && !options.EnableDecodingColorDepth10Hevc) + && options.HardwareDecodingCodecs.Contains("hevc", StringComparison.OrdinalIgnoreCase)) { - return null; + if (IsVideoStreamHevcRext(state)) + { + if (bitDepth <= 10 && !options.EnableDecodingColorDepth10HevcRext) + { + return null; + } + + if (bitDepth == 12 && !options.EnableDecodingColorDepth12HevcRext) + { + return null; + } + + if (hardwareAccelerationType == HardwareAccelerationType.vaapi + && !_mediaEncoder.IsVaapiDeviceInteliHD) + { + return null; + } + } + else if (bitDepth == 10 && !options.EnableDecodingColorDepth10Hevc) + { + return null; + } } if (string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) && options.HardwareDecodingCodecs.Contains("vp9", StringComparison.OrdinalIgnoreCase) + && bitDepth == 10 && !options.EnableDecodingColorDepth10Vp9) { return null; @@ -6081,7 +6217,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // Intel qsv/d3d11va/vaapi - if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + if (hardwareAccelerationType == HardwareAccelerationType.qsv) { if (options.PreferSystemNativeHwDecoder) { @@ -6107,7 +6243,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // Nvidia cuda - if (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + if (hardwareAccelerationType == HardwareAccelerationType.nvenc) { if (isCudaSupported && isCodecAvailable) { @@ -6124,7 +6260,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // Amd d3d11va - if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + if (hardwareAccelerationType == HardwareAccelerationType.amf) { if (isD3d11Supported && isCodecAvailable) { @@ -6134,7 +6270,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // Vaapi - if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) + if (hardwareAccelerationType == HardwareAccelerationType.vaapi && isVaapiSupported && isCodecAvailable) { @@ -6143,7 +6279,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // Apple videotoolbox - if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase) + if (hardwareAccelerationType == HardwareAccelerationType.videotoolbox && isVideotoolboxSupported && isCodecAvailable) { @@ -6151,7 +6287,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // Rockchip rkmpp - if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase) + if (hardwareAccelerationType == HardwareAccelerationType.rkmpp && isRkmppSupported && isCodecAvailable) { @@ -6167,7 +6303,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isLinux = OperatingSystem.IsLinux(); if ((!isWindows && !isLinux) - || !string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase)) + || options.HardwareAccelerationType != HardwareAccelerationType.qsv) { return null; } @@ -6185,6 +6321,14 @@ namespace MediaBrowser.Controller.MediaEncoding var is8bitSwFormatsQsv = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) || string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); var is8_10bitSwFormatsQsv = is8bitSwFormatsQsv || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10_12bitSwFormatsQsv = is8_10bitSwFormatsQsv + || string.Equals("yuv422p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv422p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv420p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv422p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); // TODO: add more 8/10bit and 4:4:4 formats for Qsv after finishing the ffcheck tool if (is8bitSwFormatsQsv) @@ -6213,12 +6357,6 @@ namespace MediaBrowser.Controller.MediaEncoding if (is8_10bitSwFormatsQsv) { - if (string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase)) - { - return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface) + GetHwDecoderName(options, "hevc", "qsv", "hevc", bitDepth); - } - if (string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase)) { return GetHwaccelType(state, options, "vp9", bitDepth, hwSurface) + GetHwDecoderName(options, "vp9", "qsv", "vp9", bitDepth); @@ -6230,13 +6368,22 @@ namespace MediaBrowser.Controller.MediaEncoding } } + if (is8_10_12bitSwFormatsQsv) + { + if (string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface) + GetHwDecoderName(options, "hevc", "qsv", "hevc", bitDepth); + } + } + return null; } public string GetNvdecVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) { if ((!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) - || !string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase)) + || options.HardwareAccelerationType != HardwareAccelerationType.nvenc) { return null; } @@ -6245,6 +6392,11 @@ namespace MediaBrowser.Controller.MediaEncoding var is8bitSwFormatsNvdec = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) || string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); var is8_10bitSwFormatsNvdec = is8bitSwFormatsNvdec || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10_12bitSwFormatsNvdec = is8_10bitSwFormatsNvdec + || string.Equals("yuv444p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv420p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); // TODO: add more 8/10/12bit and 4:4:4 formats for Nvdec after finishing the ffcheck tool if (is8bitSwFormatsNvdec) @@ -6278,12 +6430,6 @@ namespace MediaBrowser.Controller.MediaEncoding if (is8_10bitSwFormatsNvdec) { - if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) - || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) - { - return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface) + GetHwDecoderName(options, "hevc", "cuvid", "hevc", bitDepth); - } - if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { return GetHwaccelType(state, options, "vp9", bitDepth, hwSurface) + GetHwDecoderName(options, "vp9", "cuvid", "vp9", bitDepth); @@ -6295,13 +6441,22 @@ namespace MediaBrowser.Controller.MediaEncoding } } + if (is8_10_12bitSwFormatsNvdec) + { + if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface) + GetHwDecoderName(options, "hevc", "cuvid", "hevc", bitDepth); + } + } + return null; } public string GetAmfVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) { if (!OperatingSystem.IsWindows() - || !string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase)) + || options.HardwareAccelerationType != HardwareAccelerationType.amf) { return null; } @@ -6357,7 +6512,7 @@ namespace MediaBrowser.Controller.MediaEncoding public string GetVaapiVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) { if (!OperatingSystem.IsLinux() - || !string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + || options.HardwareAccelerationType != HardwareAccelerationType.vaapi) { return null; } @@ -6369,6 +6524,14 @@ namespace MediaBrowser.Controller.MediaEncoding var is8bitSwFormatsVaapi = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) || string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); var is8_10bitSwFormatsVaapi = is8bitSwFormatsVaapi || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10_12bitSwFormatsVaapi = is8_10bitSwFormatsVaapi + || string.Equals("yuv422p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv422p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv420p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv422p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); if (is8bitSwFormatsVaapi) { @@ -6396,12 +6559,6 @@ namespace MediaBrowser.Controller.MediaEncoding if (is8_10bitSwFormatsVaapi) { - if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) - || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) - { - return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface); - } - if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { return GetHwaccelType(state, options, "vp9", bitDepth, hwSurface); @@ -6413,13 +6570,22 @@ namespace MediaBrowser.Controller.MediaEncoding } } + if (is8_10_12bitSwFormatsVaapi) + { + if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) + || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "hevc", bitDepth, hwSurface); + } + } + return null; } public string GetVideotoolboxVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth) { if (!OperatingSystem.IsMacOS() - || !string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + || options.HardwareAccelerationType != HardwareAccelerationType.videotoolbox) { return null; } @@ -6427,6 +6593,14 @@ namespace MediaBrowser.Controller.MediaEncoding var is8bitSwFormatsVt = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) || string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); var is8_10bitSwFormatsVt = is8bitSwFormatsVt || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); + var is8_10_12bitSwFormatsVt = is8_10bitSwFormatsVt + || string.Equals("yuv422p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv422p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv420p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv422p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase) + || string.Equals("yuv444p12le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase); // The related patches make videotoolbox hardware surface working is only available in jellyfin-ffmpeg 7.0.1 at the moment. bool useHwSurface = (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface) && IsVideoToolboxFullSupported(); @@ -6447,16 +6621,19 @@ namespace MediaBrowser.Controller.MediaEncoding return GetHwaccelType(state, options, "h264", bitDepth, useHwSurface); } + if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return GetHwaccelType(state, options, "vp9", bitDepth, useHwSurface); + } + } + + if (is8_10_12bitSwFormatsVt) + { if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase) || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) { return GetHwaccelType(state, options, "hevc", bitDepth, useHwSurface); } - - if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase)) - { - return GetHwaccelType(state, options, "vp9", bitDepth, useHwSurface); - } } return null; @@ -6467,7 +6644,7 @@ namespace MediaBrowser.Controller.MediaEncoding var isLinux = OperatingSystem.IsLinux(); if (!isLinux - || !string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)) + || options.HardwareAccelerationType != HardwareAccelerationType.rkmpp) { return null; } @@ -6680,7 +6857,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(state.InputVideoSync)) { - inputModifier += " -vsync " + state.InputVideoSync; + inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion); } if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp) @@ -6731,7 +6908,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.IsVideoRequest) { - if (!string.IsNullOrEmpty(state.InputContainer) && state.VideoType == VideoType.VideoFile && string.IsNullOrEmpty(encodingOptions.HardwareAccelerationType)) + if (!string.IsNullOrEmpty(state.InputContainer) && state.VideoType == VideoType.VideoFile && encodingOptions.HardwareAccelerationType != HardwareAccelerationType.none) { var inputFormat = GetInputFormat(state.InputContainer); if (!string.IsNullOrEmpty(inputFormat)) @@ -6847,7 +7024,7 @@ namespace MediaBrowser.Controller.MediaEncoding state.SupportedAudioCodecs = supportedAudioCodecsList.ToArray(); - request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i)) + request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(_mediaEncoder.CanEncodeToAudioCodec) ?? state.SupportedAudioCodecs.FirstOrDefault(); } @@ -6973,7 +7150,7 @@ namespace MediaBrowser.Controller.MediaEncoding return " -codec:s:0 " + codec + " -disposition:s:0 default"; } - public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, string defaultPreset) + public string GetProgressiveVideoFullCommandLine(EncodingJobInfo state, EncodingOptions encodingOptions, EncoderPreset defaultPreset) { // Get the output codec name var videoCodec = GetVideoEncoder(state, encodingOptions); @@ -7024,7 +7201,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - public string GetProgressiveVideoArguments(EncodingJobInfo state, EncodingOptions encodingOptions, string videoCodec, string defaultPreset) + public string GetProgressiveVideoArguments(EncodingJobInfo state, EncodingOptions encodingOptions, string videoCodec, EncoderPreset defaultPreset) { var args = "-codec:v:0 " + videoCodec; @@ -7065,7 +7242,7 @@ namespace MediaBrowser.Controller.MediaEncoding args += keyFrameArg; - var hasGraphicalSubs = state.SubtitleStream is not null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasGraphicalSubs = state.SubtitleStream is not null && !state.SubtitleStream.IsTextSubtitleStream && ShouldEncodeSubtitle(state); var hasCopyTs = false; @@ -7103,7 +7280,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(state.OutputVideoSync)) { - args += " -vsync " + state.OutputVideoSync; + args += GetVideoSyncOption(state.OutputVideoSync, _mediaEncoder.EncoderVersion); } args += GetOutputFFlags(state); @@ -7270,5 +7447,39 @@ namespace MediaBrowser.Controller.MediaEncoding { return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase); } + + private static bool ShouldEncodeSubtitle(EncodingJobInfo state) + { + return state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode + || (state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding && !IsCopyCodec(state.OutputVideoCodec)); + } + + public static string GetVideoSyncOption(string videoSync, Version encoderVersion) + { + if (string.IsNullOrEmpty(videoSync)) + { + return string.Empty; + } + + if (encoderVersion >= new Version(5, 1)) + { + if (int.TryParse(videoSync, CultureInfo.InvariantCulture, out var vsync)) + { + return vsync switch + { + -1 => " -fps_mode auto", + 0 => " -fps_mode passthrough", + 1 => " -fps_mode cfr", + 2 => " -fps_mode vfr", + _ => string.Empty + }; + } + + return string.Empty; + } + + // -vsync is deprecated in FFmpeg 5.1 and will be removed in the future. + return $" -vsync {videoSync}"; + } } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 72df7151da..caa312987d 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -305,7 +305,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (BaseRequest.Static || EncodingHelper.IsCopyCodec(OutputVideoCodec)) { - return VideoStream is null ? null : (VideoStream.AverageFrameRate ?? VideoStream.RealFrameRate); + return VideoStream?.ReferenceFrameRate; } return BaseRequest.MaxFramerate ?? BaseRequest.Framerate; diff --git a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs index 5bf83a9e31..9bf27b3b2e 100644 --- a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs @@ -44,5 +44,14 @@ namespace MediaBrowser.Controller.MediaEncoding /// The cancellation token. /// System.String. Task GetSubtitleFileCharacterSet(MediaStream subtitleStream, string language, MediaSourceInfo mediaSource, CancellationToken cancellationToken); + + /// + /// Gets the path to a subtitle file. + /// + /// The subtitle stream. + /// The media source. + /// The cancellation token. + /// System.String. + Task GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs b/MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs index 67384f6f64..010d7edb4f 100644 --- a/MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs +++ b/MediaBrowser.Controller/MediaSegements/IMediaSegmentManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; @@ -13,6 +14,15 @@ namespace MediaBrowser.Controller; /// public interface IMediaSegmentManager { + /// + /// Uses all segment providers enabled for the 's library to get the Media Segments. + /// + /// The Item to evaluate. + /// If set, will remove existing segments and replace it with new ones otherwise will check for existing segments and if found any, stops. + /// stop request token. + /// A task that indicates the Operation is finished. + Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken); + /// /// Returns if this item supports media segments. /// @@ -50,4 +60,11 @@ public interface IMediaSegmentManager /// True if there are any segments stored for the item, otherwise false. /// TODO: this should be async but as the only caller BaseItem.GetVersionInfo isn't async, this is also not. Venson. bool HasSegments(Guid itemId); + + /// + /// Gets a list of all registered Segment Providers and their IDs. + /// + /// The media item that should be tested for providers. + /// A list of all providers for the tested item. + IEnumerable<(string Name, string Id)> GetSupportedProviders(BaseItem item); } diff --git a/MediaBrowser.Controller/MediaSegements/IMediaSegmentProvider.cs b/MediaBrowser.Controller/MediaSegements/IMediaSegmentProvider.cs new file mode 100644 index 0000000000..39bb58bef2 --- /dev/null +++ b/MediaBrowser.Controller/MediaSegements/IMediaSegmentProvider.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model; +using MediaBrowser.Model.MediaSegments; + +namespace MediaBrowser.Controller; + +/// +/// Provides methods for Obtaining the Media Segments from an Item. +/// +public interface IMediaSegmentProvider +{ + /// + /// Gets the provider name. + /// + string Name { get; } + + /// + /// Enumerates all Media Segments from an Media Item. + /// + /// Arguments to enumerate MediaSegments. + /// Abort token. + /// A list of all MediaSegments found from this provider. + Task> GetMediaSegments(MediaSegmentGenerationRequest request, CancellationToken cancellationToken); + + /// + /// Should return support state for the given item. + /// + /// The base item to extract segments from. + /// True if item is supported, otherwise false. + ValueTask Supports(BaseItem item); +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs index 3504831b87..8330745418 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.ComponentModel; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; @@ -8,13 +9,13 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// /// Sessions message. /// -public class SessionsMessage : OutboundWebSocketMessage> +public class SessionsMessage : OutboundWebSocketMessage> { /// /// Initializes a new instance of the class. /// /// Session info. - public SessionsMessage(IReadOnlyList data) + public SessionsMessage(IReadOnlyList data) : base(data) { } diff --git a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs index 9e91a8bcd7..0bab2a6b9c 100644 --- a/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs +++ b/MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs @@ -29,6 +29,7 @@ namespace MediaBrowser.Controller.Providers IsAutomated = copy.IsAutomated; ImageRefreshMode = copy.ImageRefreshMode; ReplaceAllImages = copy.ReplaceAllImages; + RegenerateTrickplay = copy.RegenerateTrickplay; ReplaceImages = copy.ReplaceImages; SearchResult = copy.SearchResult; RemoveOldMetadata = copy.RemoveOldMetadata; @@ -47,6 +48,12 @@ namespace MediaBrowser.Controller.Providers /// public bool ReplaceAllMetadata { get; set; } + /// + /// Gets or sets a value indicating whether all existing trickplay images should be overwritten + /// when paired with MetadataRefreshMode=FullRefresh. + /// + public bool RegenerateTrickplay { get; set; } + public MetadataRefreshMode MetadataRefreshMode { get; set; } public RemoteSearchResult SearchResult { get; set; } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 5a47236f92..462a624553 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities.Security; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; @@ -292,6 +293,17 @@ namespace MediaBrowser.Controller.Session /// SessionInfo. SessionInfo GetSession(string deviceId, string client, string version); + /// + /// Gets all sessions available to a user. + /// + /// The session identifier. + /// The device id. + /// Active within session limit. + /// Filter for sessions remote controllable for this user. + /// Is the request authenticated with API key. + /// IReadOnlyList{SessionInfoDto}. + IReadOnlyList GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey); + /// /// Gets the session by authentication token. /// diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs index 9e33588187..3ba1bfce42 100644 --- a/MediaBrowser.Controller/Session/SessionInfo.cs +++ b/MediaBrowser.Controller/Session/SessionInfo.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -27,28 +25,45 @@ namespace MediaBrowser.Controller.Session private readonly ISessionManager _sessionManager; private readonly ILogger _logger; - private readonly object _progressLock = new object(); + private readonly object _progressLock = new(); private Timer _progressTimer; private PlaybackProgressInfo _lastProgressInfo; - private bool _disposed = false; + private bool _disposed; + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + /// Instance of interface. public SessionInfo(ISessionManager sessionManager, ILogger logger) { _sessionManager = sessionManager; _logger = logger; - AdditionalUsers = Array.Empty(); + AdditionalUsers = []; PlayState = new PlayerStateInfo(); - SessionControllers = Array.Empty(); - NowPlayingQueue = Array.Empty(); - NowPlayingQueueFullItems = Array.Empty(); + SessionControllers = []; + NowPlayingQueue = []; + NowPlayingQueueFullItems = []; } + /// + /// Gets or sets the play state. + /// + /// The play state. public PlayerStateInfo PlayState { get; set; } - public SessionUserInfo[] AdditionalUsers { get; set; } + /// + /// Gets or sets the additional users. + /// + /// The additional users. + public IReadOnlyList AdditionalUsers { get; set; } + /// + /// Gets or sets the client capabilities. + /// + /// The client capabilities. public ClientCapabilities Capabilities { get; set; } /// @@ -67,7 +82,7 @@ namespace MediaBrowser.Controller.Session { if (Capabilities is null) { - return Array.Empty(); + return []; } return Capabilities.PlayableMediaTypes; @@ -134,9 +149,17 @@ namespace MediaBrowser.Controller.Session /// The now playing item. public BaseItemDto NowPlayingItem { get; set; } + /// + /// Gets or sets the now playing queue full items. + /// + /// The now playing queue full items. [JsonIgnore] public BaseItem FullNowPlayingItem { get; set; } + /// + /// Gets or sets the now viewing item. + /// + /// The now viewing item. public BaseItemDto NowViewingItem { get; set; } /// @@ -156,8 +179,12 @@ namespace MediaBrowser.Controller.Session /// /// The session controller. [JsonIgnore] - public ISessionController[] SessionControllers { get; set; } + public IReadOnlyList SessionControllers { get; set; } + /// + /// Gets or sets the transcoding info. + /// + /// The transcoding info. public TranscodingInfo TranscodingInfo { get; set; } /// @@ -177,7 +204,7 @@ namespace MediaBrowser.Controller.Session } } - if (controllers.Length > 0) + if (controllers.Count > 0) { return false; } @@ -186,6 +213,10 @@ namespace MediaBrowser.Controller.Session } } + /// + /// Gets a value indicating whether the session supports media control. + /// + /// true if this session supports media control; otherwise, false. public bool SupportsMediaControl { get @@ -208,6 +239,10 @@ namespace MediaBrowser.Controller.Session } } + /// + /// Gets a value indicating whether the session supports remote control. + /// + /// true if this session supports remote control; otherwise, false. public bool SupportsRemoteControl { get @@ -230,16 +265,40 @@ namespace MediaBrowser.Controller.Session } } + /// + /// Gets or sets the now playing queue. + /// + /// The now playing queue. public IReadOnlyList NowPlayingQueue { get; set; } + /// + /// Gets or sets the now playing queue full items. + /// + /// The now playing queue full items. public IReadOnlyList NowPlayingQueueFullItems { get; set; } + /// + /// Gets or sets a value indicating whether the session has a custom device name. + /// + /// true if this session has a custom device name; otherwise, false. public bool HasCustomDeviceName { get; set; } + /// + /// Gets or sets the playlist item id. + /// + /// The splaylist item id. public string PlaylistItemId { get; set; } + /// + /// Gets or sets the server id. + /// + /// The server id. public string ServerId { get; set; } + /// + /// Gets or sets the user primary image tag. + /// + /// The user primary image tag. public string UserPrimaryImageTag { get; set; } /// @@ -247,8 +306,14 @@ namespace MediaBrowser.Controller.Session /// /// The supported commands. public IReadOnlyList SupportedCommands - => Capabilities is null ? Array.Empty() : Capabilities.SupportedCommands; + => Capabilities is null ? [] : Capabilities.SupportedCommands; + /// + /// Ensures a controller of type exists. + /// + /// Class to register. + /// The factory. + /// Tuple{ISessionController, bool}. public Tuple EnsureController(Func factory) { var controllers = SessionControllers.ToList(); @@ -261,18 +326,27 @@ namespace MediaBrowser.Controller.Session } var newController = factory(this); - _logger.LogDebug("Creating new {0}", newController.GetType().Name); + _logger.LogDebug("Creating new {Factory}", newController.GetType().Name); controllers.Add(newController); - SessionControllers = controllers.ToArray(); + SessionControllers = [.. controllers]; return new Tuple(newController, true); } + /// + /// Adds a controller to the session. + /// + /// The controller. public void AddController(ISessionController controller) { - SessionControllers = [..SessionControllers, controller]; + SessionControllers = [.. SessionControllers, controller]; } + /// + /// Gets a value indicating whether the session contains a user. + /// + /// The user id to check. + /// true if this session contains the user; otherwise, false. public bool ContainsUser(Guid userId) { if (UserId.Equals(userId)) @@ -291,6 +365,11 @@ namespace MediaBrowser.Controller.Session return false; } + /// + /// Starts automatic progressing. + /// + /// The playback progress info. + /// The supported commands. public void StartAutomaticProgress(PlaybackProgressInfo progressInfo) { if (_disposed) @@ -359,6 +438,9 @@ namespace MediaBrowser.Controller.Session } } + /// + /// Stops automatic progressing. + /// public void StopAutomaticProgress() { lock (_progressLock) @@ -373,6 +455,10 @@ namespace MediaBrowser.Controller.Session } } + /// + /// Disposes the instance async. + /// + /// ValueTask. public async ValueTask DisposeAsync() { _disposed = true; @@ -380,7 +466,7 @@ namespace MediaBrowser.Controller.Session StopAutomaticProgress(); var controllers = SessionControllers.ToList(); - SessionControllers = Array.Empty(); + SessionControllers = []; foreach (var controller in controllers) { diff --git a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs index 0c41f30235..800317800d 100644 --- a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs +++ b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs @@ -18,9 +18,10 @@ public interface ITrickplayManager /// /// The video. /// Whether or not existing data should be replaced. + /// The library options. /// CancellationToken to use for operation. /// Task. - Task RefreshTrickplayDataAsync(Video video, bool replace, CancellationToken cancellationToken); + Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken); /// /// Creates trickplay tiles out of individual thumbnails. @@ -33,7 +34,7 @@ public interface ITrickplayManager /// /// The output directory will be DELETED and replaced if it already exists. /// - TrickplayInfo CreateTiles(List images, int width, TrickplayOptions options, string outputDir); + TrickplayInfo CreateTiles(IReadOnlyList images, int width, TrickplayOptions options, string outputDir); /// /// Get available trickplay resolutions and corresponding info. @@ -42,6 +43,14 @@ public interface ITrickplayManager /// Map of width resolutions to trickplay tiles info. Task> GetTrickplayResolutions(Guid itemId); + /// + /// Gets the item ids of all items with trickplay info. + /// + /// The limit of items to return. + /// The offset to start the query at. + /// The list of item ids that have trickplay info. + Task> GetTrickplayItemsAsync(int limit, int offset); + /// /// Saves trickplay info. /// @@ -62,8 +71,29 @@ public interface ITrickplayManager /// The item. /// The width of a single thumbnail. /// The tile's index. + /// Whether or not the tile should be saved next to the media file. /// The absolute path. - string GetTrickplayTilePath(BaseItem item, int width, int index); + Task GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia); + + /// + /// Gets the path to a trickplay tile image. + /// + /// The item. + /// The amount of images for the tile width. + /// The amount of images for the tile height. + /// The width of a single thumbnail. + /// Whether or not the tile should be saved next to the media file. + /// The absolute path. + string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false); + + /// + /// Migrates trickplay images between local and media directories. + /// + /// The video. + /// The library options. + /// CancellationToken to use for operation. + /// Task. + Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken); /// /// Gets the trickplay HLS playlist. diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 7e307286a9..431fc0b178 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -284,7 +284,7 @@ namespace MediaBrowser.MediaEncoding.Attachments if (extractableAttachmentIds.Count > 0) { - await CacheAllAttachmentsInternal(mediaPath, inputFile, mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false); + await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource), mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) @@ -323,7 +323,7 @@ namespace MediaBrowser.MediaEncoding.Attachments processArgs += string.Format( CultureInfo.InvariantCulture, - " -i \"{0}\" -t 0 -f null null", + " -i {0} -t 0 -f null null", inputFile); int exitCode; diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 2b6ed8fa09..b49fbf2aba 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -93,7 +93,8 @@ namespace MediaBrowser.MediaEncoding.Encoder "hevc_videotoolbox", "mjpeg_videotoolbox", "h264_rkmpp", - "hevc_rkmpp" + "hevc_rkmpp", + "mjpeg_rkmpp" }; private static readonly string[] _requiredFilters = new[] @@ -136,6 +137,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "flip_vulkan", // videotoolbox "yadif_videotoolbox", + "bwdif_videotoolbox", "scale_vt", "transpose_vt", "overlay_videotoolbox", diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 764230febe..826ffd0b7e 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -224,7 +224,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (OperatingSystem.IsLinux() && SupportsHwaccel("vaapi") && !string.IsNullOrEmpty(options.VaapiDevice) - && string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)) + && options.HardwareAccelerationType == HardwareAccelerationType.vaapi) { _isVaapiDeviceAmd = validator.CheckVaapiDeviceByDriverName("Mesa Gallium driver", options.VaapiDevice); _isVaapiDeviceInteliHD = validator.CheckVaapiDeviceByDriverName("Intel iHD driver", options.VaapiDevice); @@ -598,7 +598,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { try { - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, targetFormat, cancellationToken).ConfigureAwait(false); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, targetFormat, false, cancellationToken).ConfigureAwait(false); } catch (ArgumentException) { @@ -610,7 +610,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } } - return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, targetFormat, cancellationToken).ConfigureAwait(false); + return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, targetFormat, isAudio, cancellationToken).ConfigureAwait(false); } private string GetImageResolutionParameter() @@ -636,10 +636,22 @@ namespace MediaBrowser.MediaEncoding.Encoder return imageResolutionParameter; } - private async Task ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, ImageFormat? targetFormat, CancellationToken cancellationToken) + private async Task ExtractImageInternal( + string inputPath, + string container, + MediaStream videoStream, + int? imageStreamIndex, + Video3DFormat? threedFormat, + TimeSpan? offset, + bool useIFrame, + ImageFormat? targetFormat, + bool isAudio, + CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(inputPath); + var useTradeoff = _config.GetFFmpegImgExtractPerfTradeoff(); + var outputExtension = targetFormat?.GetExtension() ?? ".jpg"; var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + outputExtension); @@ -674,7 +686,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case. // mpegts need larger batch size otherwise the corrupted thumbnail will be created. Larger batch size will lower the processing speed. - var enableThumbnail = useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase); + var enableThumbnail = !useTradeoff && useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase); if (enableThumbnail) { var useLargerBatchSize = string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase); @@ -689,8 +701,9 @@ namespace MediaBrowser.MediaEncoding.Encoder { if (SupportsFilter("tonemapx")) { + var peak = videoStream.VideoRangeType == VideoRangeType.DOVI ? "400" : "100"; enableHdrExtraction = true; - filters.Add("tonemapx=tonemap=bt2390:desat=0:peak=100:t=bt709:m=bt709:p=bt709:format=yuv420p"); + filters.Add($"tonemapx=tonemap=bt2390:desat=0:peak={peak}:t=bt709:m=bt709:p=bt709:format=yuv420p"); } else if (SupportsFilter("zscale") && videoStream.VideoRangeType != VideoRangeType.DOVI) { @@ -701,13 +714,18 @@ namespace MediaBrowser.MediaEncoding.Encoder var vf = string.Join(',', filters); var mapArg = imageStreamIndex.HasValue ? (" -map 0:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; - var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2}{5} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads, GetImageResolutionParameter()); + var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2}{5} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads, isAudio ? string.Empty : GetImageResolutionParameter()); if (offset.HasValue) { args = string.Format(CultureInfo.InvariantCulture, "-ss {0} ", GetTimeParameter(offset.Value)) + args; } + if (useIFrame && useTradeoff) + { + args = "-skip_frame nokey " + args; + } + if (!string.IsNullOrWhiteSpace(container)) { var inputFormat = EncodingHelper.GetInputFormat(container); @@ -789,11 +807,13 @@ namespace MediaBrowser.MediaEncoding.Encoder if (allowHwAccel && enableKeyFrameOnlyExtraction) { - var supportsKeyFrameOnly = (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && options.EnableEnhancedNvdecDecoder) - || (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && OperatingSystem.IsWindows()) - || (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && options.PreferSystemNativeHwDecoder) - || string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase); + var hardwareAccelerationType = options.HardwareAccelerationType; + var supportsKeyFrameOnly = (hardwareAccelerationType == HardwareAccelerationType.nvenc && options.EnableEnhancedNvdecDecoder) + || (hardwareAccelerationType == HardwareAccelerationType.amf && OperatingSystem.IsWindows()) + || (hardwareAccelerationType == HardwareAccelerationType.qsv && options.PreferSystemNativeHwDecoder) + || hardwareAccelerationType == HardwareAccelerationType.vaapi + || hardwareAccelerationType == HardwareAccelerationType.videotoolbox + || hardwareAccelerationType == HardwareAccelerationType.rkmpp; if (!supportsKeyFrameOnly) { // Disable hardware acceleration when the hardware decoder does not support keyframe only mode. @@ -807,7 +827,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (!allowHwAccel) { options.EnableHardwareEncoding = false; - options.HardwareAccelerationType = string.Empty; + options.HardwareAccelerationType = HardwareAccelerationType.none; options.EnableTonemapping = false; } @@ -851,7 +871,7 @@ namespace MediaBrowser.MediaEncoding.Encoder inputArg = "-threads " + threads + " " + inputArg; // HW accel args set a different input thread count, only set if disabled } - if (options.HardwareAccelerationType.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase) && _isLowPriorityHwDecodeSupported) + if (options.HardwareAccelerationType == HardwareAccelerationType.videotoolbox && _isLowPriorityHwDecodeSupported) { // VideoToolbox supports low priority decoding, which is useful for trickplay inputArg = "-hwaccel_flags +low_priority " + inputArg; @@ -885,21 +905,30 @@ namespace MediaBrowser.MediaEncoding.Encoder throw new InvalidOperationException("Empty or invalid input argument."); } - float? encoderQuality = qualityScale; - if (vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) + // ffmpeg qscale is a value from 1-31, with 1 being best quality and 31 being worst + // jpeg quality is a value from 0-100, with 0 being worst quality and 100 being best + var encoderQuality = Math.Clamp(qualityScale ?? 4, 1, 31); + var encoderQualityOption = "-qscale:v "; + + if (vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase) + || vidEncoder.Contains("qsv", StringComparison.OrdinalIgnoreCase)) { - // vaapi's mjpeg encoder uses jpeg quality divided by QP2LAMBDA (118) as input, instead of ffmpeg defined qscale - // ffmpeg qscale is a value from 1-31, with 1 being best quality and 31 being worst - // jpeg quality is a value from 0-100, with 0 being worst quality and 100 being best - encoderQuality = (100 - ((qualityScale - 1) * (100 / 30))) / 118; + // vaapi and qsv's mjpeg encoder use jpeg quality as input, instead of ffmpeg defined qscale + encoderQuality = 100 - ((encoderQuality - 1) * (100 / 30)); + encoderQualityOption = "-global_quality:v "; } if (vidEncoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase)) { // videotoolbox's mjpeg encoder uses jpeg quality scaled to QP2LAMBDA (118) instead of ffmpeg defined qscale - // ffmpeg qscale is a value from 1-31, with 1 being best quality and 31 being worst - // jpeg quality is a value from 0-100, with 0 being worst quality and 100 being best - encoderQuality = 118 - ((qualityScale - 1) * (118 / 30)); + encoderQuality = 118 - ((encoderQuality - 1) * (118 / 30)); + } + + if (vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase)) + { + // rkmpp's mjpeg encoder uses jpeg quality as input (max is 99, not 100), instead of ffmpeg defined qscale + encoderQuality = 99 - ((encoderQuality - 1) * (99 / 30)); + encoderQualityOption = "-qp_init:v "; } // Output arguments @@ -910,13 +939,14 @@ namespace MediaBrowser.MediaEncoding.Encoder // Final command arguments var args = string.Format( CultureInfo.InvariantCulture, - "-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}{5}-f {6} \"{7}\"", + "-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}{5}{6}-f {7} \"{8}\"", inputArg, filterParam, outputThreads.GetValueOrDefault(_threads), vidEncoder, - qualityScale.HasValue ? "-qscale:v " + encoderQuality.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty, + encoderQualityOption + encoderQuality + " ", vidEncoder.Contains("videotoolbox", StringComparison.InvariantCultureIgnoreCase) ? "-allow_sw 1 " : string.Empty, // allow_sw fallback for some intel macs + EncodingHelper.GetVideoSyncOption("0", EncoderVersion).Trim() + " ", // passthrough timestamp "image2", outputPath); @@ -1082,7 +1112,11 @@ 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) + .Replace("\"", "\\\"", StringComparison.Ordinal); } /// @@ -1196,7 +1230,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // Generate concat configuration entries for each file and write to file Directory.CreateDirectory(Path.GetDirectoryName(concatFilePath)); - using StreamWriter sw = new StreamWriter(concatFilePath); + using var sw = new FormattingStreamWriter(concatFilePath, CultureInfo.InvariantCulture); foreach (var path in files) { var mediaInfoResult = GetMediaInfo( diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 9ecbfa9cf5..a731d4785b 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -529,11 +529,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles List subtitleStreams, CancellationToken cancellationToken) { - var inputPath = mediaSource.Path; + var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource); var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, - "-i \"{0}\" -copyts", + "-i {0} -copyts", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -704,7 +704,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var processArgs = string.Format( CultureInfo.InvariantCulture, - "-i \"{0}\" -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", + "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"", inputPath, subtitleStreamIndex, outputCodec, @@ -902,6 +902,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } + public async Task GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken) + { + var info = await GetReadableFile(mediaSource, subtitleStream, cancellationToken) + .ConfigureAwait(false); + return info.Path; + } + /// public void Dispose() { diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 42f355b052..57557d55ca 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -352,12 +352,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable { var audioCodec = state.ActualOutputAudioCodec; var videoCodec = state.ActualOutputVideoCodec; - var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; - HardwareEncodingType? hardwareAccelerationType = null; - if (Enum.TryParse(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType)) - { - hardwareAccelerationType = parsedHardwareAccelerationType; - } + var hardwareAccelerationType = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo { diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 4c5213d4e2..2720c0bdf6 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1819 // XML serialization handles collections improperly, so we need to use arrays + #nullable disable using MediaBrowser.Model.Entities; @@ -30,9 +32,9 @@ public class EncodingOptions EnableTonemapping = false; EnableVppTonemapping = false; EnableVideoToolboxTonemapping = false; - TonemappingAlgorithm = "bt2390"; - TonemappingMode = "auto"; - TonemappingRange = "auto"; + TonemappingAlgorithm = TonemappingAlgorithm.bt2390; + TonemappingMode = TonemappingMode.auto; + TonemappingRange = TonemappingRange.auto; TonemappingDesat = 0; TonemappingPeak = 100; TonemappingParam = 0; @@ -41,9 +43,11 @@ public class EncodingOptions H264Crf = 23; H265Crf = 28; DeinterlaceDoubleRate = false; - DeinterlaceMethod = "yadif"; + DeinterlaceMethod = DeinterlaceMethod.yadif; EnableDecodingColorDepth10Hevc = true; EnableDecodingColorDepth10Vp9 = true; + EnableDecodingColorDepth10HevcRext = false; + EnableDecodingColorDepth12HevcRext = false; // Enhanced Nvdec or system native decoder is required for DoVi to SDR tone-mapping. EnableEnhancedNvdecDecoder = true; PreferSystemNativeHwDecoder = true; @@ -53,8 +57,8 @@ public class EncodingOptions AllowHevcEncoding = false; AllowAv1Encoding = false; EnableSubtitleExtraction = true; - AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = new[] { "mkv" }; - HardwareDecodingCodecs = new string[] { "h264", "vc1" }; + AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"]; + HardwareDecodingCodecs = ["h264", "vc1"]; } /// @@ -120,7 +124,7 @@ public class EncodingOptions /// /// Gets or sets the hardware acceleration type. /// - public string HardwareAccelerationType { get; set; } + public HardwareAccelerationType HardwareAccelerationType { get; set; } /// /// Gets or sets the FFmpeg path as set by the user via the UI. @@ -160,17 +164,17 @@ public class EncodingOptions /// /// Gets or sets the tone-mapping algorithm. /// - public string TonemappingAlgorithm { get; set; } + public TonemappingAlgorithm TonemappingAlgorithm { get; set; } /// /// Gets or sets the tone-mapping mode. /// - public string TonemappingMode { get; set; } + public TonemappingMode TonemappingMode { get; set; } /// /// Gets or sets the tone-mapping range. /// - public string TonemappingRange { get; set; } + public TonemappingRange TonemappingRange { get; set; } /// /// Gets or sets the tone-mapping desaturation. @@ -210,7 +214,7 @@ public class EncodingOptions /// /// Gets or sets the encoder preset. /// - public string EncoderPreset { get; set; } + public EncoderPreset? EncoderPreset { get; set; } /// /// Gets or sets a value indicating whether the framerate is doubled when deinterlacing. @@ -220,7 +224,7 @@ public class EncodingOptions /// /// Gets or sets the deinterlace method. /// - public string DeinterlaceMethod { get; set; } + public DeinterlaceMethod DeinterlaceMethod { get; set; } /// /// Gets or sets a value indicating whether 10bit HEVC decoding is enabled. @@ -232,6 +236,16 @@ public class EncodingOptions /// public bool EnableDecodingColorDepth10Vp9 { get; set; } + /// + /// Gets or sets a value indicating whether 8/10bit HEVC RExt decoding is enabled. + /// + public bool EnableDecodingColorDepth10HevcRext { get; set; } + + /// + /// Gets or sets a value indicating whether 12bit HEVC RExt decoding is enabled. + /// + public bool EnableDecodingColorDepth12HevcRext { get; set; } + /// /// Gets or sets a value indicating whether the enhanced NVDEC is enabled. /// diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index b0f5c2a11f..6054ba34e5 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -2,15 +2,20 @@ using System; using System.ComponentModel; +using System.Linq; namespace MediaBrowser.Model.Configuration { public class LibraryOptions { + private static readonly string[] _defaultTagDelimiters = ["/", "|", ";", "\\"]; + public LibraryOptions() { TypeOptions = Array.Empty(); DisabledSubtitleFetchers = Array.Empty(); + DisabledMediaSegmentProviders = Array.Empty(); + MediaSegmentProvideOrder = Array.Empty(); SubtitleFetcherOrder = Array.Empty(); DisabledLocalMetadataReaders = Array.Empty(); DisabledLyricFetchers = Array.Empty(); @@ -24,9 +29,15 @@ namespace MediaBrowser.Model.Configuration EnablePhotos = true; SaveSubtitlesWithMedia = true; SaveLyricsWithMedia = false; + SaveTrickplayWithMedia = false; PathInfos = Array.Empty(); EnableAutomaticSeriesGrouping = true; SeasonZeroDisplayName = "Specials"; + + PreferNonstandardArtistsTag = false; + UseCustomTagDelimiters = false; + CustomTagDelimiters = _defaultTagDelimiters; + DelimiterWhitelist = Array.Empty(); } public bool Enabled { get; set; } = true; @@ -86,6 +97,10 @@ namespace MediaBrowser.Model.Configuration public string[] SubtitleFetcherOrder { get; set; } + public string[] DisabledMediaSegmentProviders { get; set; } + + public string[] MediaSegmentProvideOrder { get; set; } + public bool SkipSubtitlesIfEmbeddedSubtitlesPresent { get; set; } public bool SkipSubtitlesIfAudioTrackMatches { get; set; } @@ -99,10 +114,23 @@ namespace MediaBrowser.Model.Configuration [DefaultValue(false)] public bool SaveLyricsWithMedia { get; set; } + [DefaultValue(false)] + public bool SaveTrickplayWithMedia { get; set; } + public string[] DisabledLyricFetchers { get; set; } public string[] LyricFetcherOrder { get; set; } + [DefaultValue(false)] + public bool PreferNonstandardArtistsTag { get; set; } + + [DefaultValue(false)] + public bool UseCustomTagDelimiters { get; set; } + + public string[] CustomTagDelimiters { get; set; } + + public string[] DelimiterWhitelist { get; set; } + public bool AutomaticallyAddToCollection { get; set; } public EmbeddedSubtitleOptions AllowEmbeddedSubtitles { get; set; } diff --git a/MediaBrowser.Model/Configuration/MediaPathInfo.cs b/MediaBrowser.Model/Configuration/MediaPathInfo.cs index a7bc435901..25a5d5606b 100644 --- a/MediaBrowser.Model/Configuration/MediaPathInfo.cs +++ b/MediaBrowser.Model/Configuration/MediaPathInfo.cs @@ -16,7 +16,5 @@ namespace MediaBrowser.Model.Configuration } public string Path { get; set; } - - public string? NetworkPath { get; set; } } } diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index ef303726d1..670d6e3837 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Model.Configuration MetadataFetcher, MetadataSaver, SubtitleFetcher, - LyricFetcher + LyricFetcher, + MediaSegmentProvider } } diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 52f7e53b81..5ad588200b 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -96,8 +96,6 @@ public class ServerConfiguration : BaseApplicationConfiguration /// The metadata path. public string MetadataPath { get; set; } = string.Empty; - public string MetadataNetworkPath { get; set; } = string.Empty; - /// /// Gets or sets the preferred metadata language. /// diff --git a/MediaBrowser.Model/Devices/DeviceInfo.cs b/MediaBrowser.Model/Devices/DeviceInfo.cs index 4962992a0a..1155986138 100644 --- a/MediaBrowser.Model/Devices/DeviceInfo.cs +++ b/MediaBrowser.Model/Devices/DeviceInfo.cs @@ -1,69 +1,84 @@ -#nullable disable -#pragma warning disable CS1591 - using System; using MediaBrowser.Model.Session; -namespace MediaBrowser.Model.Devices +namespace MediaBrowser.Model.Devices; + +/// +/// A class for device Information. +/// +public class DeviceInfo { - public class DeviceInfo + /// + /// Initializes a new instance of the class. + /// + public DeviceInfo() { - public DeviceInfo() - { - Capabilities = new ClientCapabilities(); - } - - public string Name { get; set; } - - public string CustomName { get; set; } - - /// - /// Gets or sets the access token. - /// - public string AccessToken { get; set; } - - /// - /// Gets or sets the identifier. - /// - /// The identifier. - public string Id { get; set; } - - /// - /// Gets or sets the last name of the user. - /// - /// The last name of the user. - public string LastUserName { get; set; } - - /// - /// Gets or sets the name of the application. - /// - /// The name of the application. - public string AppName { get; set; } - - /// - /// Gets or sets the application version. - /// - /// The application version. - public string AppVersion { get; set; } - - /// - /// Gets or sets the last user identifier. - /// - /// The last user identifier. - public Guid LastUserId { get; set; } - - /// - /// Gets or sets the date last modified. - /// - /// The date last modified. - public DateTime DateLastActivity { get; set; } - - /// - /// Gets or sets the capabilities. - /// - /// The capabilities. - public ClientCapabilities Capabilities { get; set; } - - public string IconUrl { get; set; } + Capabilities = new ClientCapabilities(); } + + /// + /// Gets or sets the name. + /// + /// The name. + public string? Name { get; set; } + + /// + /// Gets or sets the custom name. + /// + /// The custom name. + public string? CustomName { get; set; } + + /// + /// Gets or sets the access token. + /// + /// The access token. + public string? AccessToken { get; set; } + + /// + /// Gets or sets the identifier. + /// + /// The identifier. + public string? Id { get; set; } + + /// + /// Gets or sets the last name of the user. + /// + /// The last name of the user. + public string? LastUserName { get; set; } + + /// + /// Gets or sets the name of the application. + /// + /// The name of the application. + public string? AppName { get; set; } + + /// + /// Gets or sets the application version. + /// + /// The application version. + public string? AppVersion { get; set; } + + /// + /// Gets or sets the last user identifier. + /// + /// The last user identifier. + public Guid? LastUserId { get; set; } + + /// + /// Gets or sets the date last modified. + /// + /// The date last modified. + public DateTime? DateLastActivity { get; set; } + + /// + /// Gets or sets the capabilities. + /// + /// The capabilities. + public ClientCapabilities Capabilities { get; set; } + + /// + /// Gets or sets the icon URL. + /// + /// The icon URL. + public string? IconUrl { get; set; } } diff --git a/MediaBrowser.Model/Dlna/CodecProfile.cs b/MediaBrowser.Model/Dlna/CodecProfile.cs index 07c1a29a4f..da34eddcd1 100644 --- a/MediaBrowser.Model/Dlna/CodecProfile.cs +++ b/MediaBrowser.Model/Dlna/CodecProfile.cs @@ -1,74 +1,94 @@ -#nullable disable -#pragma warning disable CS1591 - using System; +using System.Collections.Generic; +using System.Linq; using System.Xml.Serialization; -using Jellyfin.Extensions; +using MediaBrowser.Model.Extensions; -namespace MediaBrowser.Model.Dlna +namespace MediaBrowser.Model.Dlna; + +/// +/// Defines the . +/// +public class CodecProfile { - public class CodecProfile + /// + /// Initializes a new instance of the class. + /// + public CodecProfile() { - public CodecProfile() - { - Conditions = Array.Empty(); - ApplyConditions = Array.Empty(); - } + Conditions = []; + ApplyConditions = []; + } - [XmlAttribute("type")] - public CodecType Type { get; set; } + /// + /// Gets or sets the which this container must meet. + /// + [XmlAttribute("type")] + public CodecType Type { get; set; } - public ProfileCondition[] Conditions { get; set; } + /// + /// Gets or sets the list of which this profile must meet. + /// + public ProfileCondition[] Conditions { get; set; } - public ProfileCondition[] ApplyConditions { get; set; } + /// + /// Gets or sets the list of to apply if this profile is met. + /// + public ProfileCondition[] ApplyConditions { get; set; } - [XmlAttribute("codec")] - public string Codec { get; set; } + /// + /// Gets or sets the codec(s) that this profile applies to. + /// + [XmlAttribute("codec")] + public string? Codec { get; set; } - [XmlAttribute("container")] - public string Container { get; set; } + /// + /// Gets or sets the container(s) which this profile will be applied to. + /// + [XmlAttribute("container")] + public string? Container { get; set; } - [XmlAttribute("subcontainer")] - public string SubContainer { get; set; } + /// + /// Gets or sets the sub-container(s) which this profile will be applied to. + /// + [XmlAttribute("subcontainer")] + public string? SubContainer { get; set; } - public string[] GetCodecs() - { - return ContainerProfile.SplitValue(Codec); - } + /// + /// Checks to see whether the codecs and containers contain the given parameters. + /// + /// The codecs to match. + /// The container to match. + /// Consider sub-containers. + /// True if both conditions are met. + public bool ContainsAnyCodec(IReadOnlyList codecs, string? container, bool useSubContainer = false) + { + var containerToCheck = useSubContainer && string.Equals(Container, "hls", StringComparison.OrdinalIgnoreCase) ? SubContainer : Container; + return ContainerHelper.ContainsContainer(containerToCheck, container) && codecs.Any(c => ContainerHelper.ContainsContainer(Codec, false, c)); + } - private bool ContainsContainer(string container, bool useSubContainer = false) - { - var containerToCheck = useSubContainer && string.Equals(Container, "hls", StringComparison.OrdinalIgnoreCase) ? SubContainer : Container; - return ContainerProfile.ContainsContainer(containerToCheck, container); - } + /// + /// Checks to see whether the codecs and containers contain the given parameters. + /// + /// The codec to match. + /// The container to match. + /// Consider sub-containers. + /// True if both conditions are met. + public bool ContainsAnyCodec(string? codec, string? container, bool useSubContainer = false) + { + return ContainsAnyCodec(codec.AsSpan(), container, useSubContainer); + } - public bool ContainsAnyCodec(string codec, string container, bool useSubContainer = false) - { - return ContainsAnyCodec(ContainerProfile.SplitValue(codec), container, useSubContainer); - } - - public bool ContainsAnyCodec(string[] codec, string container, bool useSubContainer = false) - { - if (!ContainsContainer(container, useSubContainer)) - { - return false; - } - - var codecs = GetCodecs(); - if (codecs.Length == 0) - { - return true; - } - - foreach (var val in codec) - { - if (codecs.Contains(val, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } + /// + /// Checks to see whether the codecs and containers contain the given parameters. + /// + /// The codec to match. + /// The container to match. + /// Consider sub-containers. + /// True if both conditions are met. + public bool ContainsAnyCodec(ReadOnlySpan codec, string? container, bool useSubContainer = false) + { + var containerToCheck = useSubContainer && string.Equals(Container, "hls", StringComparison.OrdinalIgnoreCase) ? SubContainer : Container; + return ContainerHelper.ContainsContainer(containerToCheck, container) && ContainerHelper.ContainsContainer(Codec, false, codec); } } diff --git a/MediaBrowser.Model/Dlna/ContainerProfile.cs b/MediaBrowser.Model/Dlna/ContainerProfile.cs index 9780042684..a421799075 100644 --- a/MediaBrowser.Model/Dlna/ContainerProfile.cs +++ b/MediaBrowser.Model/Dlna/ContainerProfile.cs @@ -1,74 +1,49 @@ -#pragma warning disable CS1591 +#pragma warning disable CA1819 // Properties should not return arrays using System; +using System.Collections.Generic; using System.Xml.Serialization; -using Jellyfin.Extensions; +using MediaBrowser.Model.Extensions; -namespace MediaBrowser.Model.Dlna +namespace MediaBrowser.Model.Dlna; + +/// +/// Defines the . +/// +public class ContainerProfile { - public class ContainerProfile + /// + /// Gets or sets the which this container must meet. + /// + [XmlAttribute("type")] + public DlnaProfileType Type { get; set; } + + /// + /// Gets or sets the list of which this container will be applied to. + /// + public ProfileCondition[] Conditions { get; set; } = []; + + /// + /// Gets or sets the container(s) which this container must meet. + /// + [XmlAttribute("container")] + public string? Container { get; set; } + + /// + /// Gets or sets the sub container(s) which this container must meet. + /// + [XmlAttribute("subcontainer")] + public string? SubContainer { get; set; } + + /// + /// Returns true if an item in appears in the property. + /// + /// The item to match. + /// Consider subcontainers. + /// The result of the operation. + public bool ContainsContainer(ReadOnlySpan container, bool useSubContainer = false) { - [XmlAttribute("type")] - public DlnaProfileType Type { get; set; } - - public ProfileCondition[] Conditions { get; set; } = Array.Empty(); - - [XmlAttribute("container")] - public string Container { get; set; } = string.Empty; - - public static string[] SplitValue(string? value) - { - if (string.IsNullOrEmpty(value)) - { - return Array.Empty(); - } - - return value.Split(',', StringSplitOptions.RemoveEmptyEntries); - } - - public bool ContainsContainer(string? container) - { - var containers = SplitValue(Container); - - return ContainsContainer(containers, container); - } - - public static bool ContainsContainer(string? profileContainers, string? inputContainer) - { - var isNegativeList = false; - if (profileContainers is not null && profileContainers.StartsWith('-')) - { - isNegativeList = true; - profileContainers = profileContainers.Substring(1); - } - - return ContainsContainer(SplitValue(profileContainers), isNegativeList, inputContainer); - } - - public static bool ContainsContainer(string[]? profileContainers, string? inputContainer) - { - return ContainsContainer(profileContainers, false, inputContainer); - } - - public static bool ContainsContainer(string[]? profileContainers, bool isNegativeList, string? inputContainer) - { - if (profileContainers is null || profileContainers.Length == 0) - { - // Empty profiles always support all containers/codecs - return true; - } - - var allInputContainers = SplitValue(inputContainer); - - foreach (var container in allInputContainers) - { - if (profileContainers.Contains(container, StringComparison.OrdinalIgnoreCase)) - { - return !isNegativeList; - } - } - - return isNegativeList; - } + var containerToCheck = useSubContainer && string.Equals(Container, "hls", StringComparison.OrdinalIgnoreCase) ? SubContainer : Container; + return ContainerHelper.ContainsContainer(containerToCheck, container); } } diff --git a/MediaBrowser.Model/Dlna/DeviceProfile.cs b/MediaBrowser.Model/Dlna/DeviceProfile.cs index 2addebbfca..995b7633a9 100644 --- a/MediaBrowser.Model/Dlna/DeviceProfile.cs +++ b/MediaBrowser.Model/Dlna/DeviceProfile.cs @@ -1,74 +1,71 @@ #pragma warning disable CA1819 // Properties should not return arrays using System; -using System.Xml.Serialization; -namespace MediaBrowser.Model.Dlna +namespace MediaBrowser.Model.Dlna; + +/// +/// A represents a set of metadata which determines which content a certain device is able to play. +///
+/// Specifically, it defines the supported containers and +/// codecs (video and/or audio, including codec profiles and levels) +/// the device is able to direct play (without transcoding or remuxing), +/// as well as which containers/codecs to transcode to in case it isn't. +///
+public class DeviceProfile { /// - /// A represents a set of metadata which determines which content a certain device is able to play. - ///
- /// Specifically, it defines the supported containers and - /// codecs (video and/or audio, including codec profiles and levels) - /// the device is able to direct play (without transcoding or remuxing), - /// as well as which containers/codecs to transcode to in case it isn't. + /// Gets or sets the name of this device profile. User profiles must have a unique name. ///
- public class DeviceProfile - { - /// - /// Gets or sets the name of this device profile. - /// - public string? Name { get; set; } + public string? Name { get; set; } - /// - /// Gets or sets the Id. - /// - [XmlIgnore] - public string? Id { get; set; } + /// + /// Gets or sets the unique internal identifier. + /// + public Guid? Id { get; set; } - /// - /// Gets or sets the maximum allowed bitrate for all streamed content. - /// - public int? MaxStreamingBitrate { get; set; } = 8000000; + /// + /// Gets or sets the maximum allowed bitrate for all streamed content. + /// + public int? MaxStreamingBitrate { get; set; } = 8000000; - /// - /// Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files). - /// - public int? MaxStaticBitrate { get; set; } = 8000000; + /// + /// Gets or sets the maximum allowed bitrate for statically streamed content (= direct played files). + /// + public int? MaxStaticBitrate { get; set; } = 8000000; - /// - /// Gets or sets the maximum allowed bitrate for transcoded music streams. - /// - public int? MusicStreamingTranscodingBitrate { get; set; } = 128000; + /// + /// Gets or sets the maximum allowed bitrate for transcoded music streams. + /// + public int? MusicStreamingTranscodingBitrate { get; set; } = 128000; - /// - /// Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files. - /// - public int? MaxStaticMusicBitrate { get; set; } = 8000000; + /// + /// Gets or sets the maximum allowed bitrate for statically streamed (= direct played) music files. + /// + public int? MaxStaticMusicBitrate { get; set; } = 8000000; - /// - /// Gets or sets the direct play profiles. - /// - public DirectPlayProfile[] DirectPlayProfiles { get; set; } = Array.Empty(); + /// + /// Gets or sets the direct play profiles. + /// + public DirectPlayProfile[] DirectPlayProfiles { get; set; } = []; - /// - /// Gets or sets the transcoding profiles. - /// - public TranscodingProfile[] TranscodingProfiles { get; set; } = Array.Empty(); + /// + /// Gets or sets the transcoding profiles. + /// + public TranscodingProfile[] TranscodingProfiles { get; set; } = []; - /// - /// Gets or sets the container profiles. - /// - public ContainerProfile[] ContainerProfiles { get; set; } = Array.Empty(); + /// + /// Gets or sets the container profiles. Failing to meet these optional conditions causes transcoding to occur. + /// + public ContainerProfile[] ContainerProfiles { get; set; } = []; - /// - /// Gets or sets the codec profiles. - /// - public CodecProfile[] CodecProfiles { get; set; } = Array.Empty(); + /// + /// Gets or sets the codec profiles. + /// + public CodecProfile[] CodecProfiles { get; set; } = []; - /// - /// Gets or sets the subtitle profiles. - /// - public SubtitleProfile[] SubtitleProfiles { get; set; } = Array.Empty(); - } + /// + /// Gets or sets the subtitle profiles. + /// + public SubtitleProfile[] SubtitleProfiles { get; set; } = []; } diff --git a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs index f68235d869..438df34415 100644 --- a/MediaBrowser.Model/Dlna/DirectPlayProfile.cs +++ b/MediaBrowser.Model/Dlna/DirectPlayProfile.cs @@ -1,36 +1,65 @@ -#pragma warning disable CS1591 - using System.Xml.Serialization; +using MediaBrowser.Model.Extensions; -namespace MediaBrowser.Model.Dlna +namespace MediaBrowser.Model.Dlna; + +/// +/// Defines the . +/// +public class DirectPlayProfile { - public class DirectPlayProfile + /// + /// Gets or sets the container. + /// + [XmlAttribute("container")] + public string Container { get; set; } = string.Empty; + + /// + /// Gets or sets the audio codec. + /// + [XmlAttribute("audioCodec")] + public string? AudioCodec { get; set; } + + /// + /// Gets or sets the video codec. + /// + [XmlAttribute("videoCodec")] + public string? VideoCodec { get; set; } + + /// + /// Gets or sets the Dlna profile type. + /// + [XmlAttribute("type")] + public DlnaProfileType Type { get; set; } + + /// + /// Returns whether the supports the . + /// + /// The container to match against. + /// True if supported. + public bool SupportsContainer(string? container) { - [XmlAttribute("container")] - public string? Container { get; set; } + return ContainerHelper.ContainsContainer(Container, container); + } - [XmlAttribute("audioCodec")] - public string? AudioCodec { get; set; } + /// + /// Returns whether the supports the . + /// + /// The codec to match against. + /// True if supported. + public bool SupportsVideoCodec(string? codec) + { + return Type == DlnaProfileType.Video && ContainerHelper.ContainsContainer(VideoCodec, codec); + } - [XmlAttribute("videoCodec")] - public string? VideoCodec { get; set; } - - [XmlAttribute("type")] - public DlnaProfileType Type { get; set; } - - public bool SupportsContainer(string? container) - { - return ContainerProfile.ContainsContainer(Container, container); - } - - public bool SupportsVideoCodec(string? codec) - { - return Type == DlnaProfileType.Video && ContainerProfile.ContainsContainer(VideoCodec, codec); - } - - public bool SupportsAudioCodec(string? codec) - { - return (Type == DlnaProfileType.Audio || Type == DlnaProfileType.Video) && ContainerProfile.ContainsContainer(AudioCodec, codec); - } + /// + /// Returns whether the supports the . + /// + /// The codec to match against. + /// True if supported. + public bool SupportsAudioCodec(string? codec) + { + // Video profiles can have audio codec restrictions too, therefore incude Video as valid type. + return (Type == DlnaProfileType.Audio || Type == DlnaProfileType.Video) && ContainerHelper.ContainsContainer(AudioCodec, codec); } } diff --git a/MediaBrowser.Model/Dlna/MediaOptions.cs b/MediaBrowser.Model/Dlna/MediaOptions.cs index eca971e95e..6b26ca94b5 100644 --- a/MediaBrowser.Model/Dlna/MediaOptions.cs +++ b/MediaBrowser.Model/Dlna/MediaOptions.cs @@ -49,6 +49,11 @@ namespace MediaBrowser.Model.Dlna ///
public bool AllowVideoStreamCopy { get; set; } + /// + /// Gets or sets a value indicating whether always burn in subtitles when transcoding. + /// + public bool AlwaysBurnInSubtitleWhenTranscoding { get; set; } + /// /// Gets or sets the item id. /// diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs index 5d7daa81aa..1a636b2403 100644 --- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs +++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs @@ -2,28 +2,33 @@ #pragma warning disable CS1591 using System; +using System.Linq; namespace MediaBrowser.Model.Dlna { public static class ResolutionNormalizer { - private static readonly ResolutionConfiguration[] Configurations = - new[] - { - new ResolutionConfiguration(426, 320000), - new ResolutionConfiguration(640, 400000), - new ResolutionConfiguration(720, 950000), - new ResolutionConfiguration(1280, 2500000), - new ResolutionConfiguration(1920, 4000000), - new ResolutionConfiguration(2560, 20000000), - new ResolutionConfiguration(3840, 35000000) - }; + // Please note: all bitrate here are in the scale of SDR h264 bitrate at 30fps + private static readonly ResolutionConfiguration[] _configurations = + [ + new ResolutionConfiguration(416, 365000), + new ResolutionConfiguration(640, 730000), + new ResolutionConfiguration(768, 1100000), + new ResolutionConfiguration(960, 3000000), + new ResolutionConfiguration(1280, 6000000), + new ResolutionConfiguration(1920, 13500000), + new ResolutionConfiguration(2560, 28000000), + new ResolutionConfiguration(3840, 50000000) + ]; public static ResolutionOptions Normalize( int? inputBitrate, int outputBitrate, + int h264EquivalentOutputBitrate, int? maxWidth, - int? maxHeight) + int? maxHeight, + float? targetFps, + bool isHdr = false) // We are not doing HDR transcoding for now, leave for future use { // If the bitrate isn't changing, then don't downscale the resolution if (inputBitrate.HasValue && outputBitrate >= inputBitrate.Value) @@ -38,16 +43,26 @@ namespace MediaBrowser.Model.Dlna } } - var resolutionConfig = GetResolutionConfiguration(outputBitrate); - if (resolutionConfig is not null) - { - var originvalValue = maxWidth; + var referenceBitrate = h264EquivalentOutputBitrate * (30.0f / (targetFps ?? 30.0f)); - maxWidth = Math.Min(resolutionConfig.MaxWidth, maxWidth ?? resolutionConfig.MaxWidth); - if (!originvalValue.HasValue || originvalValue.Value != maxWidth.Value) - { - maxHeight = null; - } + if (isHdr) + { + referenceBitrate *= 0.8f; + } + + var resolutionConfig = GetResolutionConfiguration(Convert.ToInt32(referenceBitrate)); + + if (resolutionConfig is null) + { + return new ResolutionOptions { MaxWidth = maxWidth, MaxHeight = maxHeight }; + } + + var originWidthValue = maxWidth; + + maxWidth = Math.Min(resolutionConfig.MaxWidth, maxWidth ?? resolutionConfig.MaxWidth); + if (!originWidthValue.HasValue || originWidthValue.Value != maxWidth.Value) + { + maxHeight = null; } return new ResolutionOptions @@ -59,19 +74,7 @@ namespace MediaBrowser.Model.Dlna private static ResolutionConfiguration GetResolutionConfiguration(int outputBitrate) { - ResolutionConfiguration previousOption = null; - - foreach (var config in Configurations) - { - if (outputBitrate <= config.MaxBitrate) - { - return previousOption ?? config; - } - - previousOption = config; - } - - return null; + return _configurations.FirstOrDefault(config => outputBitrate <= config.MaxBitrate); } } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 7f387bfaae..a25ddc367d 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -6,6 +6,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Extensions; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; @@ -19,15 +20,17 @@ namespace MediaBrowser.Model.Dlna { // Aliases internal const TranscodeReason ContainerReasons = TranscodeReason.ContainerNotSupported | TranscodeReason.ContainerBitrateExceedsLimit; - internal const TranscodeReason AudioReasons = TranscodeReason.AudioCodecNotSupported | TranscodeReason.AudioBitrateNotSupported | TranscodeReason.AudioChannelsNotSupported | TranscodeReason.AudioProfileNotSupported | TranscodeReason.AudioSampleRateNotSupported | TranscodeReason.SecondaryAudioNotSupported | TranscodeReason.AudioBitDepthNotSupported | TranscodeReason.AudioIsExternal; - internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported; - internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported; + internal const TranscodeReason AudioCodecReasons = TranscodeReason.AudioBitrateNotSupported | TranscodeReason.AudioChannelsNotSupported | TranscodeReason.AudioProfileNotSupported | TranscodeReason.AudioSampleRateNotSupported | TranscodeReason.SecondaryAudioNotSupported | TranscodeReason.AudioBitDepthNotSupported | TranscodeReason.AudioIsExternal; + internal const TranscodeReason AudioReasons = TranscodeReason.AudioCodecNotSupported | AudioCodecReasons; + internal const TranscodeReason VideoCodecReasons = TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.VideoProfileNotSupported; + internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons; + internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported; private readonly ILogger _logger; private readonly ITranscoderSupport _transcoderSupport; - private static readonly string[] _supportedHlsVideoCodecs = new string[] { "h264", "hevc", "vp9", "av1" }; - private static readonly string[] _supportedHlsAudioCodecsTs = new string[] { "aac", "ac3", "eac3", "mp3" }; - private static readonly string[] _supportedHlsAudioCodecsMp4 = new string[] { "aac", "ac3", "eac3", "mp3", "alac", "flac", "opus", "dca", "truehd" }; + private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"]; + private static readonly string[] _supportedHlsAudioCodecsTs = ["aac", "ac3", "eac3", "mp3"]; + private static readonly string[] _supportedHlsAudioCodecsMp4 = ["aac", "ac3", "eac3", "mp3", "alac", "flac", "opus", "dca", "truehd"]; /// /// Initializes a new instance of the class. @@ -49,7 +52,7 @@ namespace MediaBrowser.Model.Dlna { ValidateMediaOptions(options, false); - var streams = new List(); + List streams = []; foreach (var mediaSource in options.MediaSources) { if (!(string.IsNullOrEmpty(options.MediaSourceId) @@ -62,7 +65,7 @@ namespace MediaBrowser.Model.Dlna if (streamInfo is not null) { streamInfo.DeviceId = options.DeviceId; - streamInfo.DeviceProfileId = options.Profile.Id; + streamInfo.DeviceProfileId = options.Profile.Id?.ToString("N", CultureInfo.InvariantCulture); streams.Add(streamInfo); } } @@ -127,7 +130,7 @@ namespace MediaBrowser.Model.Dlna if (directPlayMethod is PlayMethod.DirectStream) { var remuxContainer = item.TranscodingContainer ?? "ts"; - var supportedHlsContainers = new[] { "ts", "mp4" }; + string[] supportedHlsContainers = ["ts", "mp4"]; // If the container specified for the profile is an HLS supported container, use that container instead, overriding the preference // The client should be responsible to ensure this container is compatible remuxContainer = Array.Exists(supportedHlsContainers, element => string.Equals(element, directPlayInfo.Profile?.Container, StringComparison.OrdinalIgnoreCase)) ? directPlayInfo.Profile?.Container : remuxContainer; @@ -224,7 +227,7 @@ namespace MediaBrowser.Model.Dlna ? options.MediaSources : options.MediaSources.Where(x => string.Equals(x.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase)); - var streams = new List(); + List streams = []; foreach (var mediaSourceInfo in mediaSources) { var streamInfo = BuildVideoItem(mediaSourceInfo, options); @@ -237,7 +240,7 @@ namespace MediaBrowser.Model.Dlna foreach (var stream in streams) { stream.DeviceId = options.DeviceId; - stream.DeviceProfileId = options.Profile.Id; + stream.DeviceProfileId = options.Profile.Id?.ToString("N", CultureInfo.InvariantCulture); } return GetOptimalStream(streams, options.GetMaxBitrate(false) ?? 0); @@ -352,7 +355,7 @@ namespace MediaBrowser.Model.Dlna return TranscodeReason.VideoBitrateNotSupported; case ProfileConditionValue.VideoCodecTag: - return TranscodeReason.VideoCodecNotSupported; + return TranscodeReason.VideoCodecTagNotSupported; case ProfileConditionValue.VideoFramerate: return TranscodeReason.VideoFramerateNotSupported; @@ -388,30 +391,31 @@ namespace MediaBrowser.Model.Dlna /// The normalized input container. public static string? NormalizeMediaSourceFormatIntoSingleContainer(string inputContainer, DeviceProfile? profile, DlnaProfileType type, DirectPlayProfile? playProfile = null) { - if (string.IsNullOrEmpty(inputContainer)) + // If the source is Live TV the inputContainer will be null until the mediasource is probed on first access + if (profile is null || string.IsNullOrEmpty(inputContainer) || !inputContainer.Contains(',', StringComparison.OrdinalIgnoreCase)) { - return null; + return inputContainer; } - var formats = ContainerProfile.SplitValue(inputContainer); - - if (profile is not null) + var formats = ContainerHelper.Split(inputContainer); + var playProfiles = playProfile is null ? profile.DirectPlayProfiles : [playProfile]; + foreach (var format in formats) { - var playProfiles = playProfile is null ? profile.DirectPlayProfiles : new[] { playProfile }; - foreach (var format in formats) + foreach (var directPlayProfile in playProfiles) { - foreach (var directPlayProfile in playProfiles) + if (directPlayProfile.Type != type) { - if (directPlayProfile.Type == type - && directPlayProfile.SupportsContainer(format)) - { - return format; - } + continue; + } + + if (directPlayProfile.SupportsContainer(format)) + { + return format; } } } - return formats[0]; + return inputContainer; } private (DirectPlayProfile? Profile, PlayMethod? PlayMethod, TranscodeReason TranscodeReasons) GetAudioDirectPlayProfile(MediaSourceInfo item, MediaStream audioStream, MediaOptions options) @@ -531,7 +535,6 @@ namespace MediaBrowser.Model.Dlna private static int? GetDefaultSubtitleStreamIndex(MediaSourceInfo item, SubtitleProfile[] subtitleProfiles) { int highestScore = -1; - foreach (var stream in item.MediaStreams) { if (stream.Type == MediaStreamType.Subtitle @@ -542,7 +545,7 @@ namespace MediaBrowser.Model.Dlna } } - var topStreams = new List(); + List topStreams = []; foreach (var stream in item.MediaStreams) { if (stream.Type == MediaStreamType.Subtitle && stream.Score.HasValue && stream.Score.Value == highestScore) @@ -621,8 +624,8 @@ namespace MediaBrowser.Model.Dlna playlistItem.Container = container; playlistItem.SubProtocol = protocol; - playlistItem.VideoCodecs = new[] { item.VideoStream.Codec }; - playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile?.AudioCodec); + playlistItem.VideoCodecs = [item.VideoStream.Codec]; + playlistItem.AudioCodecs = ContainerHelper.Split(directPlayProfile?.AudioCodec); } private StreamInfo BuildVideoItem(MediaSourceInfo item, MediaOptions options) @@ -637,7 +640,8 @@ namespace MediaBrowser.Model.Dlna RunTimeTicks = item.RunTimeTicks, Context = options.Context, DeviceProfile = options.Profile, - SubtitleStreamIndex = options.SubtitleStreamIndex ?? GetDefaultSubtitleStreamIndex(item, options.Profile.SubtitleProfiles) + SubtitleStreamIndex = options.SubtitleStreamIndex ?? GetDefaultSubtitleStreamIndex(item, options.Profile.SubtitleProfiles), + AlwaysBurnInSubtitleWhenTranscoding = options.AlwaysBurnInSubtitleWhenTranscoding }; var subtitleStream = playlistItem.SubtitleStreamIndex.HasValue ? item.GetMediaStream(MediaStreamType.Subtitle, playlistItem.SubtitleStreamIndex.Value) : null; @@ -649,7 +653,7 @@ namespace MediaBrowser.Model.Dlna } // Collect candidate audio streams - ICollection candidateAudioStreams = audioStream is null ? Array.Empty() : new[] { audioStream }; + ICollection candidateAudioStreams = audioStream is null ? [] : [audioStream]; if (!options.AudioStreamIndex.HasValue || options.AudioStreamIndex < 0) { if (audioStream?.IsDefault == true) @@ -700,7 +704,8 @@ namespace MediaBrowser.Model.Dlna directPlayProfile = directPlayInfo.Profile; playlistItem.PlayMethod = directPlay.Value; playlistItem.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile); - playlistItem.VideoCodecs = new[] { videoStream.Codec }; + var videoCodec = videoStream?.Codec; + playlistItem.VideoCodecs = videoCodec is null ? [] : [videoCodec]; if (directPlay == PlayMethod.DirectPlay) { @@ -711,7 +716,7 @@ namespace MediaBrowser.Model.Dlna { playlistItem.AudioStreamIndex = audioStreamIndex; var audioCodec = item.GetMediaStream(MediaStreamType.Audio, audioStreamIndex.Value)?.Codec; - playlistItem.AudioCodecs = audioCodec is null ? Array.Empty() : new[] { audioCodec }; + playlistItem.AudioCodecs = audioCodec is null ? [] : [audioCodec]; } } else if (directPlay == PlayMethod.DirectStream) @@ -719,7 +724,7 @@ namespace MediaBrowser.Model.Dlna playlistItem.AudioStreamIndex = audioStream?.Index; if (audioStream is not null) { - playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile?.AudioCodec); + playlistItem.AudioCodecs = ContainerHelper.Split(directPlayProfile?.AudioCodec); } SetStreamInfoOptionsFromDirectPlayProfile(options, item, playlistItem, directPlayProfile); @@ -751,8 +756,9 @@ namespace MediaBrowser.Model.Dlna { // Can't direct play, find the transcoding profile // If we do this for direct-stream we will overwrite the info - var transcodingProfile = GetVideoTranscodeProfile(item, options, videoStream, audioStream, candidateAudioStreams, subtitleStream, playlistItem); - if (transcodingProfile is not null) + var (transcodingProfile, playMethod) = GetVideoTranscodeProfile(item, options, videoStream, audioStream, playlistItem); + + if (transcodingProfile is not null && playMethod.HasValue) { SetStreamInfoOptionsFromTranscodingProfile(item, playlistItem, transcodingProfile); @@ -763,10 +769,9 @@ namespace MediaBrowser.Model.Dlna if (subtitleStream is not null) { var subtitleProfile = GetSubtitleProfile(item, subtitleStream, options.Profile.SubtitleProfiles, PlayMethod.Transcode, _transcoderSupport, transcodingProfile.Container, transcodingProfile.Protocol); - playlistItem.SubtitleDeliveryMethod = subtitleProfile.Method; playlistItem.SubtitleFormat = subtitleProfile.Format; - playlistItem.SubtitleCodecs = new[] { subtitleProfile.Format }; + playlistItem.SubtitleCodecs = [subtitleProfile.Format]; } if ((playlistItem.TranscodeReasons & (VideoReasons | TranscodeReason.ContainerBitrateExceedsLimit)) != 0) @@ -790,58 +795,94 @@ namespace MediaBrowser.Model.Dlna return playlistItem; } - private TranscodingProfile? GetVideoTranscodeProfile( + private (TranscodingProfile? Profile, PlayMethod? PlayMethod) GetVideoTranscodeProfile( MediaSourceInfo item, MediaOptions options, MediaStream? videoStream, MediaStream? audioStream, - IEnumerable candidateAudioStreams, - MediaStream? subtitleStream, StreamInfo playlistItem) { if (!(item.SupportsTranscoding || item.SupportsDirectStream)) { - return null; + return (null, null); } var transcodingProfiles = options.Profile.TranscodingProfiles .Where(i => i.Type == playlistItem.MediaType && i.Context == options.Context); - if (options.AllowVideoStreamCopy) + if (item.UseMostCompatibleTranscodingProfile) { - // prefer direct copy profile - float videoFramerate = videoStream?.AverageFrameRate ?? videoStream?.RealFrameRate ?? 0; - TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp; - int? numAudioStreams = item.GetStreamCount(MediaStreamType.Audio); - int? numVideoStreams = item.GetStreamCount(MediaStreamType.Video); - - transcodingProfiles = transcodingProfiles.ToLookup(transcodingProfile => - { - var videoCodecs = ContainerProfile.SplitValue(transcodingProfile.VideoCodec); - - if (ContainerProfile.ContainsContainer(videoCodecs, item.VideoStream?.Codec)) - { - var videoCodec = videoStream?.Codec; - var container = transcodingProfile.Container; - var appliedVideoConditions = options.Profile.CodecProfiles - .Where(i => i.Type == CodecType.Video && - i.ContainsAnyCodec(videoCodec, container) && - i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, videoStream?.Width, videoStream?.Height, videoStream?.BitDepth, videoStream?.BitRate, videoStream?.Profile, videoStream?.VideoRangeType, videoStream?.Level, videoFramerate, videoStream?.PacketLength, timestamp, videoStream?.IsAnamorphic, videoStream?.IsInterlaced, videoStream?.RefFrames, numVideoStreams, numAudioStreams, videoStream?.CodecTag, videoStream?.IsAVC))) - .Select(i => - i.Conditions.All(condition => ConditionProcessor.IsVideoConditionSatisfied(condition, videoStream?.Width, videoStream?.Height, videoStream?.BitDepth, videoStream?.BitRate, videoStream?.Profile, videoStream?.VideoRangeType, videoStream?.Level, videoFramerate, videoStream?.PacketLength, timestamp, videoStream?.IsAnamorphic, videoStream?.IsInterlaced, videoStream?.RefFrames, numVideoStreams, numAudioStreams, videoStream?.CodecTag, videoStream?.IsAVC))); - - // An empty appliedVideoConditions means that the codec has no conditions for the current video stream - var conditionsSatisfied = appliedVideoConditions.All(satisfied => satisfied); - return conditionsSatisfied ? 1 : 2; - } - - return 3; - }) - .OrderBy(lookup => lookup.Key) - .SelectMany(lookup => lookup); + transcodingProfiles = transcodingProfiles.Where(i => string.Equals(i.Container, "ts", StringComparison.OrdinalIgnoreCase)); } - return transcodingProfiles.FirstOrDefault(); + var videoCodec = videoStream?.Codec; + float videoFramerate = videoStream?.ReferenceFrameRate ?? 0; + TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp; + int? numAudioStreams = item.GetStreamCount(MediaStreamType.Audio); + int? numVideoStreams = item.GetStreamCount(MediaStreamType.Video); + + var audioCodec = audioStream?.Codec; + var audioProfile = audioStream?.Profile; + var audioChannels = audioStream?.Channels; + var audioBitrate = audioStream?.BitRate; + var audioSampleRate = audioStream?.SampleRate; + var audioBitDepth = audioStream?.BitDepth; + + var analyzedProfiles = transcodingProfiles + .Select(transcodingProfile => + { + var rank = (Video: 3, Audio: 3); + + var container = transcodingProfile.Container; + + if (options.AllowVideoStreamCopy) + { + if (ContainerHelper.ContainsContainer(transcodingProfile.VideoCodec, videoCodec)) + { + var appliedVideoConditions = options.Profile.CodecProfiles + .Where(i => i.Type == CodecType.Video && + i.ContainsAnyCodec(videoCodec, container) && + i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, videoStream?.Width, videoStream?.Height, videoStream?.BitDepth, videoStream?.BitRate, videoStream?.Profile, videoStream?.VideoRangeType, videoStream?.Level, videoFramerate, videoStream?.PacketLength, timestamp, videoStream?.IsAnamorphic, videoStream?.IsInterlaced, videoStream?.RefFrames, numVideoStreams, numAudioStreams, videoStream?.CodecTag, videoStream?.IsAVC))) + .Select(i => + i.Conditions.All(condition => ConditionProcessor.IsVideoConditionSatisfied(condition, videoStream?.Width, videoStream?.Height, videoStream?.BitDepth, videoStream?.BitRate, videoStream?.Profile, videoStream?.VideoRangeType, videoStream?.Level, videoFramerate, videoStream?.PacketLength, timestamp, videoStream?.IsAnamorphic, videoStream?.IsInterlaced, videoStream?.RefFrames, numVideoStreams, numAudioStreams, videoStream?.CodecTag, videoStream?.IsAVC))); + + // An empty appliedVideoConditions means that the codec has no conditions for the current video stream + var conditionsSatisfied = appliedVideoConditions.All(satisfied => satisfied); + rank.Video = conditionsSatisfied ? 1 : 2; + } + } + + if (options.AllowAudioStreamCopy) + { + if (ContainerHelper.ContainsContainer(transcodingProfile.AudioCodec, audioCodec)) + { + var appliedVideoConditions = options.Profile.CodecProfiles + .Where(i => i.Type == CodecType.VideoAudio && + i.ContainsAnyCodec(audioCodec, container) && + i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, false))) + .Select(i => + i.Conditions.All(condition => ConditionProcessor.IsVideoAudioConditionSatisfied(condition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, false))); + + // An empty appliedVideoConditions means that the codec has no conditions for the current audio stream + var conditionsSatisfied = appliedVideoConditions.All(satisfied => satisfied); + rank.Audio = conditionsSatisfied ? 1 : 2; + } + } + + PlayMethod playMethod = PlayMethod.Transcode; + + if (rank.Video == 1) + { + playMethod = PlayMethod.DirectStream; + } + + return (Profile: transcodingProfile, PlayMethod: playMethod, Rank: rank); + }) + .OrderBy(analysis => analysis.Rank); + + var profileMatch = analyzedProfiles.FirstOrDefault(); + + return (profileMatch.Profile, profileMatch.PlayMethod); } private void BuildStreamVideoItem( @@ -856,26 +897,24 @@ namespace MediaBrowser.Model.Dlna string? audioCodec) { // Prefer matching video codecs - var videoCodecs = ContainerProfile.SplitValue(videoCodec); + var videoCodecs = ContainerHelper.Split(videoCodec).ToList(); + + if (videoCodecs.Count == 0 && videoStream is not null) + { + // Add the original codec if no codec is specified + videoCodecs.Add(videoStream.Codec); + } // Enforce HLS video codec restrictions if (playlistItem.SubProtocol == MediaStreamProtocol.hls) { - videoCodecs = videoCodecs.Where(codec => _supportedHlsVideoCodecs.Contains(codec)).ToArray(); - } - - var directVideoCodec = ContainerProfile.ContainsContainer(videoCodecs, videoStream?.Codec) ? videoStream?.Codec : null; - if (directVideoCodec is not null) - { - // merge directVideoCodec to videoCodecs - Array.Resize(ref videoCodecs, videoCodecs.Length + 1); - videoCodecs[^1] = directVideoCodec; + videoCodecs = videoCodecs.Where(codec => _supportedHlsVideoCodecs.Contains(codec)).ToList(); } playlistItem.VideoCodecs = videoCodecs; // Copy video codec options as a starting point, this applies to transcode and direct-stream - playlistItem.MaxFramerate = videoStream?.AverageFrameRate; + playlistItem.MaxFramerate = videoStream?.ReferenceFrameRate; var qualifier = videoStream?.Codec; if (videoStream?.Level is not null) { @@ -893,22 +932,28 @@ namespace MediaBrowser.Model.Dlna } // Prefer matching audio codecs, could do better here - var audioCodecs = ContainerProfile.SplitValue(audioCodec); + var audioCodecs = ContainerHelper.Split(audioCodec).ToList(); + + if (audioCodecs.Count == 0 && audioStream is not null) + { + // Add the original codec if no codec is specified + audioCodecs.Add(audioStream.Codec); + } // Enforce HLS audio codec restrictions if (playlistItem.SubProtocol == MediaStreamProtocol.hls) { if (string.Equals(playlistItem.Container, "mp4", StringComparison.OrdinalIgnoreCase)) { - audioCodecs = audioCodecs.Where(codec => _supportedHlsAudioCodecsMp4.Contains(codec)).ToArray(); + audioCodecs = audioCodecs.Where(codec => _supportedHlsAudioCodecsMp4.Contains(codec)).ToList(); } else { - audioCodecs = audioCodecs.Where(codec => _supportedHlsAudioCodecsTs.Contains(codec)).ToArray(); + audioCodecs = audioCodecs.Where(codec => _supportedHlsAudioCodecsTs.Contains(codec)).ToList(); } } - var audioStreamWithSupportedCodec = candidateAudioStreams.Where(stream => ContainerProfile.ContainsContainer(audioCodecs, stream.Codec)).FirstOrDefault(); + var audioStreamWithSupportedCodec = candidateAudioStreams.Where(stream => ContainerHelper.ContainsContainer(audioCodecs, false, stream.Codec)).FirstOrDefault(); var directAudioStream = audioStreamWithSupportedCodec?.Channels is not null && audioStreamWithSupportedCodec.Channels.Value <= (playlistItem.TranscodingMaxAudioChannels ?? int.MaxValue) ? audioStreamWithSupportedCodec : null; @@ -925,7 +970,8 @@ namespace MediaBrowser.Model.Dlna { audioStream = directAudioStream; playlistItem.AudioStreamIndex = audioStream.Index; - playlistItem.AudioCodecs = new[] { audioStream.Codec }; + audioCodecs = [audioStream.Codec]; + playlistItem.AudioCodecs = audioCodecs; // Copy matching audio codec options playlistItem.AudioSampleRate = audioStream.SampleRate; @@ -949,7 +995,7 @@ namespace MediaBrowser.Model.Dlna double? videoLevel = videoStream?.Level; string? videoProfile = videoStream?.Profile; VideoRangeType? videoRangeType = videoStream?.VideoRangeType; - float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0; + float videoFramerate = videoStream is null ? 0 : videoStream.ReferenceFrameRate ?? 0; bool? isAnamorphic = videoStream?.IsAnamorphic; bool? isInterlaced = videoStream?.IsInterlaced; string? videoCodecTag = videoStream?.CodecTag; @@ -966,19 +1012,17 @@ namespace MediaBrowser.Model.Dlna var appliedVideoConditions = options.Profile.CodecProfiles .Where(i => i.Type == CodecType.Video && - i.ContainsAnyCodec(videoStream?.Codec, container, useSubContainer) && + i.ContainsAnyCodec(playlistItem.VideoCodecs, container, useSubContainer) && i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc))) // Reverse codec profiles for backward compatibility - first codec profile has higher priority .Reverse(); - - foreach (var i in appliedVideoConditions) + foreach (var condition in appliedVideoConditions) { - var transcodingVideoCodecs = ContainerProfile.SplitValue(videoCodec); - foreach (var transcodingVideoCodec in transcodingVideoCodecs) + foreach (var transcodingVideoCodec in playlistItem.VideoCodecs) { - if (i.ContainsAnyCodec(transcodingVideoCodec, container, useSubContainer)) + if (condition.ContainsAnyCodec(transcodingVideoCodec, container, useSubContainer)) { - ApplyTranscodingConditions(playlistItem, i.Conditions, transcodingVideoCodec, true, true); + ApplyTranscodingConditions(playlistItem, condition.Conditions, transcodingVideoCodec, true, true); continue; } } @@ -999,15 +1043,14 @@ namespace MediaBrowser.Model.Dlna var appliedAudioConditions = options.Profile.CodecProfiles .Where(i => i.Type == CodecType.VideoAudio && - i.ContainsAnyCodec(audioStream?.Codec, container) && + i.ContainsAnyCodec(playlistItem.AudioCodecs, container) && i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, audioProfile, isSecondaryAudio))) // Reverse codec profiles for backward compatibility - first codec profile has higher priority .Reverse(); foreach (var codecProfile in appliedAudioConditions) { - var transcodingAudioCodecs = ContainerProfile.SplitValue(audioCodec); - foreach (var transcodingAudioCodec in transcodingAudioCodecs) + foreach (var transcodingAudioCodec in playlistItem.AudioCodecs) { if (codecProfile.ContainsAnyCodec(transcodingAudioCodec, container)) { @@ -1077,9 +1120,9 @@ namespace MediaBrowser.Model.Dlna return 192000; } - private static int GetAudioBitrate(long maxTotalBitrate, string[] targetAudioCodecs, MediaStream? audioStream, StreamInfo item) + private static int GetAudioBitrate(long maxTotalBitrate, IReadOnlyList targetAudioCodecs, MediaStream? audioStream, StreamInfo item) { - string? targetAudioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; + string? targetAudioCodec = targetAudioCodecs.Count == 0 ? null : targetAudioCodecs[0]; int? targetAudioChannels = item.GetTargetAudioChannels(targetAudioCodec); @@ -1096,7 +1139,7 @@ namespace MediaBrowser.Model.Dlna && audioStream.Channels.HasValue && audioStream.Channels.Value > targetAudioChannels.Value) { - // Reduce the bitrate if we're downmixing. + // Reduce the bitrate if we're down mixing. defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels); } else if (targetAudioChannels.HasValue @@ -1104,8 +1147,8 @@ namespace MediaBrowser.Model.Dlna && audioStream.Channels.Value <= targetAudioChannels.Value && !string.IsNullOrEmpty(audioStream.Codec) && targetAudioCodecs is not null - && targetAudioCodecs.Length > 0 - && !Array.Exists(targetAudioCodecs, elem => string.Equals(audioStream.Codec, elem, StringComparison.OrdinalIgnoreCase))) + && targetAudioCodecs.Count > 0 + && !targetAudioCodecs.Any(elem => string.Equals(audioStream.Codec, elem, StringComparison.OrdinalIgnoreCase))) { // Shift the bitrate if we're transcoding to a different audio codec. defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, audioStream.Channels.Value); @@ -1208,7 +1251,7 @@ namespace MediaBrowser.Model.Dlna double? videoLevel = videoStream?.Level; string? videoProfile = videoStream?.Profile; VideoRangeType? videoRangeType = videoStream?.VideoRangeType; - float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0; + float videoFramerate = videoStream is null ? 0 : videoStream.ReferenceFrameRate ?? 0; bool? isAnamorphic = videoStream?.IsAnamorphic; bool? isInterlaced = videoStream?.IsInterlaced; string? videoCodecTag = videoStream?.CodecTag; @@ -1244,7 +1287,7 @@ namespace MediaBrowser.Model.Dlna !checkVideoConditions(codecProfile.ApplyConditions).Any()) .SelectMany(codecProfile => checkVideoConditions(codecProfile.Conditions))); - // Check audiocandidates profile conditions + // Check audio candidates profile conditions var audioStreamMatches = candidateAudioStreams.ToDictionary(s => s, audioStream => CheckVideoAudioStreamDirectPlay(options, mediaSource, container, audioStream)); TranscodeReason subtitleProfileReasons = 0; @@ -1261,25 +1304,8 @@ namespace MediaBrowser.Model.Dlna } } - var rankings = new[] { VideoReasons, AudioReasons, ContainerReasons }; - var rank = (ref TranscodeReason a) => - { - var index = 1; - foreach (var flag in rankings) - { - var reason = a & flag; - if (reason != 0) - { - return index; - } - - index++; - } - - return index; - }; - var containerSupported = false; + TranscodeReason[] rankings = [TranscodeReason.VideoCodecNotSupported, VideoCodecReasons, TranscodeReason.AudioCodecNotSupported, AudioCodecReasons, ContainerReasons]; // Check DirectPlay profiles to see if it can be direct played var analyzedProfiles = profile.DirectPlayProfiles @@ -1345,7 +1371,8 @@ namespace MediaBrowser.Model.Dlna playMethod = PlayMethod.DirectStream; } - var ranked = rank(ref failureReasons); + var ranked = GetRank(ref failureReasons, rankings); + return (Result: (Profile: directPlayProfile, PlayMethod: playMethod, AudioStreamIndex: selectedAudioStream?.Index, TranscodeReason: failureReasons), Order: order, Rank: ranked); }) .OrderByDescending(analysis => analysis.Result.PlayMethod) @@ -1364,7 +1391,7 @@ namespace MediaBrowser.Model.Dlna var failureReasons = analyzedProfiles[false] .Select(analysis => analysis.Result) - .Where(result => !containerSupported || (result.TranscodeReason & TranscodeReason.ContainerNotSupported) == 0) + .Where(result => !containerSupported || !result.TranscodeReason.HasFlag(TranscodeReason.ContainerNotSupported)) .FirstOrDefault().TranscodeReason; if (failureReasons == 0) { @@ -1420,7 +1447,7 @@ namespace MediaBrowser.Model.Dlna /// The . /// The . /// The output container. - /// The subtitle transoding protocol. + /// The subtitle transcoding protocol. /// The normalized input container. public static SubtitleProfile GetSubtitleProfile( MediaSourceInfo mediaSource, @@ -1446,7 +1473,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!ContainerProfile.ContainsContainer(profile.Container, outputContainer)) + if (!ContainerHelper.ContainsContainer(profile.Container, outputContainer)) { continue; } @@ -1475,7 +1502,7 @@ namespace MediaBrowser.Model.Dlna continue; } - if (!ContainerProfile.ContainsContainer(profile.Container, outputContainer)) + if (!ContainerHelper.ContainsContainer(profile.Container, outputContainer)) { continue; } @@ -1506,17 +1533,12 @@ namespace MediaBrowser.Model.Dlna { if (!string.IsNullOrEmpty(transcodingContainer)) { - string[] normalizedContainers = ContainerProfile.SplitValue(transcodingContainer); - - if (ContainerProfile.ContainsContainer(normalizedContainers, "ts") - || ContainerProfile.ContainsContainer(normalizedContainers, "mpegts") - || ContainerProfile.ContainsContainer(normalizedContainers, "mp4")) + if (ContainerHelper.ContainsContainer(transcodingContainer, "ts,mpegts,mp4")) { return false; } - if (ContainerProfile.ContainsContainer(normalizedContainers, "mkv") - || ContainerProfile.ContainsContainer(normalizedContainers, "matroska")) + if (ContainerHelper.ContainsContainer(transcodingContainer, "mkv,matroska")) { return true; } @@ -2219,5 +2241,22 @@ namespace MediaBrowser.Model.Dlna return false; } + + private int GetRank(ref TranscodeReason a, TranscodeReason[] rankings) + { + var index = 1; + foreach (var flag in rankings) + { + var reason = a & flag; + if (reason != 0) + { + return index; + } + + index++; + } + + return index; + } } } diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index c8a341d413..1ae4e1962d 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -1,9 +1,6 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; using Jellyfin.Data.Enums; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; @@ -11,1007 +8,1308 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; -namespace MediaBrowser.Model.Dlna +namespace MediaBrowser.Model.Dlna; + +/// +/// Class holding information on a stream. +/// +public class StreamInfo { /// - /// Class StreamInfo. + /// Initializes a new instance of the class. /// - public class StreamInfo + public StreamInfo() { - public StreamInfo() + AudioCodecs = []; + VideoCodecs = []; + SubtitleCodecs = []; + StreamOptions = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets or sets the item id. + /// + /// The item id. + public Guid ItemId { get; set; } + + /// + /// Gets or sets the play method. + /// + /// The play method. + public PlayMethod PlayMethod { get; set; } + + /// + /// Gets or sets the encoding context. + /// + /// The encoding context. + public EncodingContext Context { get; set; } + + /// + /// Gets or sets the media type. + /// + /// The media type. + public DlnaProfileType MediaType { get; set; } + + /// + /// Gets or sets the container. + /// + /// The container. + public string? Container { get; set; } + + /// + /// Gets or sets the sub protocol. + /// + /// The sub protocol. + public MediaStreamProtocol SubProtocol { get; set; } + + /// + /// Gets or sets the start position ticks. + /// + /// The start position ticks. + public long StartPositionTicks { get; set; } + + /// + /// Gets or sets the segment length. + /// + /// The segment length. + public int? SegmentLength { get; set; } + + /// + /// Gets or sets the minimum segments count. + /// + /// The minimum segments count. + public int? MinSegments { get; set; } + + /// + /// Gets or sets a value indicating whether the stream can be broken on non-keyframes. + /// + public bool BreakOnNonKeyFrames { get; set; } + + /// + /// Gets or sets a value indicating whether the stream requires AVC. + /// + public bool RequireAvc { get; set; } + + /// + /// Gets or sets a value indicating whether the stream requires AVC. + /// + public bool RequireNonAnamorphic { get; set; } + + /// + /// Gets or sets a value indicating whether timestamps should be copied. + /// + public bool CopyTimestamps { get; set; } + + /// + /// Gets or sets a value indicating whether timestamps should be copied. + /// + public bool EnableMpegtsM2TsMode { get; set; } + + /// + /// Gets or sets a value indicating whether the subtitle manifest is enabled. + /// + public bool EnableSubtitlesInManifest { get; set; } + + /// + /// Gets or sets the audio codecs. + /// + /// The audio codecs. + public IReadOnlyList AudioCodecs { get; set; } + + /// + /// Gets or sets the video codecs. + /// + /// The video codecs. + public IReadOnlyList VideoCodecs { get; set; } + + /// + /// Gets or sets the audio stream index. + /// + /// The audio stream index. + public int? AudioStreamIndex { get; set; } + + /// + /// Gets or sets the video stream index. + /// + /// The subtitle stream index. + public int? SubtitleStreamIndex { get; set; } + + /// + /// Gets or sets the maximum transcoding audio channels. + /// + /// The maximum transcoding audio channels. + public int? TranscodingMaxAudioChannels { get; set; } + + /// + /// Gets or sets the global maximum audio channels. + /// + /// The global maximum audio channels. + public int? GlobalMaxAudioChannels { get; set; } + + /// + /// Gets or sets the audio bitrate. + /// + /// The audio bitrate. + public int? AudioBitrate { get; set; } + + /// + /// Gets or sets the audio sample rate. + /// + /// The audio sample rate. + public int? AudioSampleRate { get; set; } + + /// + /// Gets or sets the video bitrate. + /// + /// The video bitrate. + public int? VideoBitrate { get; set; } + + /// + /// Gets or sets the maximum output width. + /// + /// The output width. + public int? MaxWidth { get; set; } + + /// + /// Gets or sets the maximum output height. + /// + /// The maximum output height. + public int? MaxHeight { get; set; } + + /// + /// Gets or sets the maximum framerate. + /// + /// The maximum framerate. + public float? MaxFramerate { get; set; } + + /// + /// Gets or sets the device profile. + /// + /// The device profile. + public required DeviceProfile DeviceProfile { get; set; } + + /// + /// Gets or sets the device profile id. + /// + /// The device profile id. + public string? DeviceProfileId { get; set; } + + /// + /// Gets or sets the device id. + /// + /// The device id. + public string? DeviceId { get; set; } + + /// + /// Gets or sets the runtime ticks. + /// + /// The runtime ticks. + public long? RunTimeTicks { get; set; } + + /// + /// Gets or sets the transcode seek info. + /// + /// The transcode seek info. + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + /// + /// Gets or sets a value indicating whether content length should be estimated. + /// + public bool EstimateContentLength { get; set; } + + /// + /// Gets or sets the media source info. + /// + /// The media source info. + public MediaSourceInfo? MediaSource { get; set; } + + /// + /// Gets or sets the subtitle codecs. + /// + /// The subtitle codecs. + public IReadOnlyList SubtitleCodecs { get; set; } + + /// + /// Gets or sets the subtitle delivery method. + /// + /// The subtitle delivery method. + public SubtitleDeliveryMethod SubtitleDeliveryMethod { get; set; } + + /// + /// Gets or sets the subtitle format. + /// + /// The subtitle format. + public string? SubtitleFormat { get; set; } + + /// + /// Gets or sets the play session id. + /// + /// The play session id. + public string? PlaySessionId { get; set; } + + /// + /// Gets or sets the transcode reasons. + /// + /// The transcode reasons. + public TranscodeReason TranscodeReasons { get; set; } + + /// + /// Gets the stream options. + /// + /// The stream options. + public Dictionary StreamOptions { get; private set; } + + /// + /// Gets the media source id. + /// + /// The media source id. + public string? MediaSourceId => MediaSource?.Id; + + /// + /// Gets or sets a value indicating whether audio VBR encoding is enabled. + /// + public bool EnableAudioVbrEncoding { get; set; } + + /// + /// Gets or sets a value indicating whether always burn in subtitles when transcoding. + /// + public bool AlwaysBurnInSubtitleWhenTranscoding { get; set; } + + /// + /// Gets a value indicating whether the stream is direct. + /// + public bool IsDirectStream => MediaSource?.VideoType is not (VideoType.Dvd or VideoType.BluRay) + && PlayMethod is PlayMethod.DirectStream or PlayMethod.DirectPlay; + + /// + /// Gets the audio stream that will be used in the output stream. + /// + /// The audio stream. + public MediaStream? TargetAudioStream => MediaSource?.GetDefaultAudioStream(AudioStreamIndex); + + /// + /// Gets the video stream that will be used in the output stream. + /// + /// The video stream. + public MediaStream? TargetVideoStream => MediaSource?.VideoStream; + + /// + /// Gets the audio sample rate that will be in the output stream. + /// + /// The target audio sample rate. + public int? TargetAudioSampleRate + { + get { - AudioCodecs = Array.Empty(); - VideoCodecs = Array.Empty(); - SubtitleCodecs = Array.Empty(); - StreamOptions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var stream = TargetAudioStream; + return AudioSampleRate.HasValue && !IsDirectStream + ? AudioSampleRate + : stream?.SampleRate; } + } - public Guid ItemId { get; set; } - - public PlayMethod PlayMethod { get; set; } - - public EncodingContext Context { get; set; } - - public DlnaProfileType MediaType { get; set; } - - public string? Container { get; set; } - - public MediaStreamProtocol SubProtocol { get; set; } - - public long StartPositionTicks { get; set; } - - public int? SegmentLength { get; set; } - - public int? MinSegments { get; set; } - - public bool BreakOnNonKeyFrames { get; set; } - - public bool RequireAvc { get; set; } - - public bool RequireNonAnamorphic { get; set; } - - public bool CopyTimestamps { get; set; } - - public bool EnableMpegtsM2TsMode { get; set; } - - public bool EnableSubtitlesInManifest { get; set; } - - public string[] AudioCodecs { get; set; } - - public string[] VideoCodecs { get; set; } - - public int? AudioStreamIndex { get; set; } - - public int? SubtitleStreamIndex { get; set; } - - public int? TranscodingMaxAudioChannels { get; set; } - - public int? GlobalMaxAudioChannels { get; set; } - - public int? AudioBitrate { get; set; } - - public int? AudioSampleRate { get; set; } - - public int? VideoBitrate { get; set; } - - public int? MaxWidth { get; set; } - - public int? MaxHeight { get; set; } - - public float? MaxFramerate { get; set; } - - public required DeviceProfile DeviceProfile { get; set; } - - public string? DeviceProfileId { get; set; } - - public string? DeviceId { get; set; } - - public long? RunTimeTicks { get; set; } - - public TranscodeSeekInfo TranscodeSeekInfo { get; set; } - - public bool EstimateContentLength { get; set; } - - public MediaSourceInfo? MediaSource { get; set; } - - public string[] SubtitleCodecs { get; set; } - - public SubtitleDeliveryMethod SubtitleDeliveryMethod { get; set; } - - public string? SubtitleFormat { get; set; } - - public string? PlaySessionId { get; set; } - - public TranscodeReason TranscodeReasons { get; set; } - - public Dictionary StreamOptions { get; private set; } - - public string? MediaSourceId => MediaSource?.Id; - - public bool EnableAudioVbrEncoding { get; set; } - - public bool IsDirectStream => MediaSource?.VideoType is not (VideoType.Dvd or VideoType.BluRay) - && PlayMethod is PlayMethod.DirectStream or PlayMethod.DirectPlay; - - /// - /// Gets the audio stream that will be used. - /// - public MediaStream? TargetAudioStream => MediaSource?.GetDefaultAudioStream(AudioStreamIndex); - - /// - /// Gets the video stream that will be used. - /// - public MediaStream? TargetVideoStream => MediaSource?.VideoStream; - - /// - /// Gets the audio sample rate that will be in the output stream. - /// - public int? TargetAudioSampleRate + /// + /// Gets the audio bit depth that will be in the output stream. + /// + /// The target bit depth. + public int? TargetAudioBitDepth + { + get { - get + if (IsDirectStream) { - var stream = TargetAudioStream; - return AudioSampleRate.HasValue && !IsDirectStream - ? AudioSampleRate - : stream?.SampleRate; - } - } - - /// - /// Gets the audio sample rate that will be in the output stream. - /// - public int? TargetAudioBitDepth - { - get - { - if (IsDirectStream) - { - return TargetAudioStream?.BitDepth; - } - - var targetAudioCodecs = TargetAudioCodec; - var audioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; - if (!string.IsNullOrEmpty(audioCodec)) - { - return GetTargetAudioBitDepth(audioCodec); - } - return TargetAudioStream?.BitDepth; } - } - /// - /// Gets the audio sample rate that will be in the output stream. - /// - public int? TargetVideoBitDepth - { - get + var targetAudioCodecs = TargetAudioCodec; + var audioCodec = targetAudioCodecs.Count == 0 ? null : targetAudioCodecs[0]; + if (!string.IsNullOrEmpty(audioCodec)) { - if (IsDirectStream) - { - return TargetVideoStream?.BitDepth; - } + return GetTargetAudioBitDepth(audioCodec); + } - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) - { - return GetTargetVideoBitDepth(videoCodec); - } + return TargetAudioStream?.BitDepth; + } + } + /// + /// Gets the video bit depth that will be in the output stream. + /// + /// The target video bit depth. + public int? TargetVideoBitDepth + { + get + { + if (IsDirectStream) + { return TargetVideoStream?.BitDepth; } - } - /// - /// Gets the target reference frames. - /// - /// The target reference frames. - public int? TargetRefFrames - { - get + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Count == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) { - if (IsDirectStream) - { - return TargetVideoStream?.RefFrames; - } + return GetTargetVideoBitDepth(videoCodec); + } - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) - { - return GetTargetRefFrames(videoCodec); - } + return TargetVideoStream?.BitDepth; + } + } + /// + /// Gets the target reference frames that will be in the output stream. + /// + /// The target reference frames. + public int? TargetRefFrames + { + get + { + if (IsDirectStream) + { return TargetVideoStream?.RefFrames; } - } - /// - /// Gets the audio sample rate that will be in the output stream. - /// - public float? TargetFramerate - { - get + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Count == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) { - var stream = TargetVideoStream; - return MaxFramerate.HasValue && !IsDirectStream - ? MaxFramerate - : stream is null ? null : stream.AverageFrameRate ?? stream.RealFrameRate; + return GetTargetRefFrames(videoCodec); } + + return TargetVideoStream?.RefFrames; } + } - /// - /// Gets the audio sample rate that will be in the output stream. - /// - public double? TargetVideoLevel + /// + /// Gets the target framerate that will be in the output stream. + /// + /// The target framerate. + public float? TargetFramerate + { + get { - get + var stream = TargetVideoStream; + return MaxFramerate.HasValue && !IsDirectStream + ? MaxFramerate + : stream?.ReferenceFrameRate; + } + } + + /// + /// Gets the target video level that will be in the output stream. + /// + /// The target video level. + public double? TargetVideoLevel + { + get + { + if (IsDirectStream) { - if (IsDirectStream) - { - return TargetVideoStream?.Level; - } - - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) - { - return GetTargetVideoLevel(videoCodec); - } - return TargetVideoStream?.Level; } - } - /// - /// Gets the audio sample rate that will be in the output stream. - /// - public int? TargetPacketLength - { - get + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Count == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) { - var stream = TargetVideoStream; - return !IsDirectStream - ? null - : stream?.PacketLength; + return GetTargetVideoLevel(videoCodec); } + + return TargetVideoStream?.Level; } + } - /// - /// Gets the audio sample rate that will be in the output stream. - /// - public string? TargetVideoProfile + /// + /// Gets the target packet length that will be in the output stream. + /// + /// The target packet length. + public int? TargetPacketLength + { + get { - get + var stream = TargetVideoStream; + return !IsDirectStream + ? null + : stream?.PacketLength; + } + } + + /// + /// Gets the target video profile that will be in the output stream. + /// + /// The target video profile. + public string? TargetVideoProfile + { + get + { + if (IsDirectStream) { - if (IsDirectStream) - { - return TargetVideoStream?.Profile; - } - - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) - { - return GetOption(videoCodec, "profile"); - } - return TargetVideoStream?.Profile; } - } - /// - /// Gets the target video range type that will be in the output stream. - /// - public VideoRangeType TargetVideoRangeType - { - get + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Count == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) { - if (IsDirectStream) - { - return TargetVideoStream?.VideoRangeType ?? VideoRangeType.Unknown; - } + return GetOption(videoCodec, "profile"); + } - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec) - && Enum.TryParse(GetOption(videoCodec, "rangetype"), true, out VideoRangeType videoRangeType)) - { - return videoRangeType; - } + return TargetVideoStream?.Profile; + } + } + /// + /// Gets the target video range type that will be in the output stream. + /// + /// The video range type. + public VideoRangeType TargetVideoRangeType + { + get + { + if (IsDirectStream) + { return TargetVideoStream?.VideoRangeType ?? VideoRangeType.Unknown; } - } - /// - /// Gets the target video codec tag. - /// - /// The target video codec tag. - public string? TargetVideoCodecTag - { - get + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Count == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec) + && Enum.TryParse(GetOption(videoCodec, "rangetype"), true, out VideoRangeType videoRangeType)) { - var stream = TargetVideoStream; - return !IsDirectStream - ? null - : stream?.CodecTag; + return videoRangeType; } + + return TargetVideoStream?.VideoRangeType ?? VideoRangeType.Unknown; } + } - /// - /// Gets the audio bitrate that will be in the output stream. - /// - public int? TargetAudioBitrate + /// + /// Gets the target video codec tag. + /// + /// The video codec tag. + public string? TargetVideoCodecTag + { + get { - get - { - var stream = TargetAudioStream; - return AudioBitrate.HasValue && !IsDirectStream - ? AudioBitrate - : stream?.BitRate; - } + var stream = TargetVideoStream; + return !IsDirectStream + ? null + : stream?.CodecTag; } + } - /// - /// Gets the audio channels that will be in the output stream. - /// - public int? TargetAudioChannels + /// + /// Gets the audio bitrate that will be in the output stream. + /// + /// The audio bitrate. + public int? TargetAudioBitrate + { + get { - get + var stream = TargetAudioStream; + return AudioBitrate.HasValue && !IsDirectStream + ? AudioBitrate + : stream?.BitRate; + } + } + + /// + /// Gets the amount of audio channels that will be in the output stream. + /// + /// The target audio channels. + public int? TargetAudioChannels + { + get + { + if (IsDirectStream) { - if (IsDirectStream) - { - return TargetAudioStream?.Channels; - } - - var targetAudioCodecs = TargetAudioCodec; - var codec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0]; - if (!string.IsNullOrEmpty(codec)) - { - return GetTargetRefFrames(codec); - } - return TargetAudioStream?.Channels; } - } - /// - /// Gets the audio codec that will be in the output stream. - /// - public string[] TargetAudioCodec - { - get + var targetAudioCodecs = TargetAudioCodec; + var codec = targetAudioCodecs.Count == 0 ? null : targetAudioCodecs[0]; + if (!string.IsNullOrEmpty(codec)) { - var stream = TargetAudioStream; + return GetTargetRefFrames(codec); + } - string? inputCodec = stream?.Codec; + return TargetAudioStream?.Channels; + } + } - if (IsDirectStream) + /// + /// Gets the audio codec that will be in the output stream. + /// + /// The audio codec. + public IReadOnlyList TargetAudioCodec + { + get + { + var stream = TargetAudioStream; + + string? inputCodec = stream?.Codec; + + if (IsDirectStream) + { + return string.IsNullOrEmpty(inputCodec) ? [] : [inputCodec]; + } + + foreach (string codec in AudioCodecs) + { + if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) { - return string.IsNullOrEmpty(inputCodec) ? Array.Empty() : new[] { inputCodec }; + return string.IsNullOrEmpty(codec) ? [] : [codec]; } + } - foreach (string codec in AudioCodecs) + return AudioCodecs; + } + } + + /// + /// Gets the video codec that will be in the output stream. + /// + /// The target video codec. + public IReadOnlyList TargetVideoCodec + { + get + { + var stream = TargetVideoStream; + + string? inputCodec = stream?.Codec; + + if (IsDirectStream) + { + return string.IsNullOrEmpty(inputCodec) ? [] : [inputCodec]; + } + + foreach (string codec in VideoCodecs) + { + if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) - { - return string.IsNullOrEmpty(codec) ? Array.Empty() : new[] { codec }; - } + return string.IsNullOrEmpty(codec) ? [] : [codec]; } - - return AudioCodecs; - } - } - - public string[] TargetVideoCodec - { - get - { - var stream = TargetVideoStream; - - string? inputCodec = stream?.Codec; - - if (IsDirectStream) - { - return string.IsNullOrEmpty(inputCodec) ? Array.Empty() : new[] { inputCodec }; - } - - foreach (string codec in VideoCodecs) - { - if (string.Equals(codec, inputCodec, StringComparison.OrdinalIgnoreCase)) - { - return string.IsNullOrEmpty(codec) ? Array.Empty() : new[] { codec }; - } - } - - return VideoCodecs; - } - } - - /// - /// Gets the audio channels that will be in the output stream. - /// - public long? TargetSize - { - get - { - if (IsDirectStream) - { - return MediaSource?.Size; - } - - if (RunTimeTicks.HasValue) - { - int? totalBitrate = TargetTotalBitrate; - - double totalSeconds = RunTimeTicks.Value; - // Convert to ms - totalSeconds /= 10000; - // Convert to seconds - totalSeconds /= 1000; - - return totalBitrate.HasValue ? - Convert.ToInt64(totalBitrate.Value * totalSeconds) : - null; - } - - return null; - } - } - - public int? TargetVideoBitrate - { - get - { - var stream = TargetVideoStream; - - return VideoBitrate.HasValue && !IsDirectStream - ? VideoBitrate - : stream?.BitRate; - } - } - - public TransportStreamTimestamp TargetTimestamp - { - get - { - var defaultValue = string.Equals(Container, "m2ts", StringComparison.OrdinalIgnoreCase) - ? TransportStreamTimestamp.Valid - : TransportStreamTimestamp.None; - - return !IsDirectStream - ? defaultValue - : MediaSource is null ? defaultValue : MediaSource.Timestamp ?? TransportStreamTimestamp.None; - } - } - - public int? TargetTotalBitrate => (TargetAudioBitrate ?? 0) + (TargetVideoBitrate ?? 0); - - public bool? IsTargetAnamorphic - { - get - { - if (IsDirectStream) - { - return TargetVideoStream?.IsAnamorphic; - } - - return false; - } - } - - public bool? IsTargetInterlaced - { - get - { - if (IsDirectStream) - { - return TargetVideoStream?.IsInterlaced; - } - - var targetVideoCodecs = TargetVideoCodec; - var videoCodec = targetVideoCodecs.Length == 0 ? null : targetVideoCodecs[0]; - if (!string.IsNullOrEmpty(videoCodec)) - { - if (string.Equals(GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - - return TargetVideoStream?.IsInterlaced; - } - } - - public bool? IsTargetAVC - { - get - { - if (IsDirectStream) - { - return TargetVideoStream?.IsAVC; - } - - return true; - } - } - - public int? TargetWidth - { - get - { - var videoStream = TargetVideoStream; - - if (videoStream is not null && videoStream.Width.HasValue && videoStream.Height.HasValue) - { - ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); - - size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); - - return size.Width; - } - - return MaxWidth; - } - } - - public int? TargetHeight - { - get - { - var videoStream = TargetVideoStream; - - if (videoStream is not null && videoStream.Width.HasValue && videoStream.Height.HasValue) - { - ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); - - size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); - - return size.Height; - } - - return MaxHeight; - } - } - - public int? TargetVideoStreamCount - { - get - { - if (IsDirectStream) - { - return GetMediaStreamCount(MediaStreamType.Video, int.MaxValue); - } - - return GetMediaStreamCount(MediaStreamType.Video, 1); - } - } - - public int? TargetAudioStreamCount - { - get - { - if (IsDirectStream) - { - return GetMediaStreamCount(MediaStreamType.Audio, int.MaxValue); - } - - return GetMediaStreamCount(MediaStreamType.Audio, 1); - } - } - - public void SetOption(string? qualifier, string name, string value) - { - if (string.IsNullOrEmpty(qualifier)) - { - SetOption(name, value); - } - else - { - SetOption(qualifier + "-" + name, value); - } - } - - public void SetOption(string name, string value) - { - StreamOptions[name] = value; - } - - public string? GetOption(string? qualifier, string name) - { - var value = GetOption(qualifier + "-" + name); - - if (string.IsNullOrEmpty(value)) - { - value = GetOption(name); } - return value; + return VideoCodecs; } + } - public string? GetOption(string name) + /// + /// Gets the target size of the output stream. + /// + /// The target size. + public long? TargetSize + { + get { - if (StreamOptions.TryGetValue(name, out var value)) + if (IsDirectStream) { - return value; + return MediaSource?.Size; + } + + if (RunTimeTicks.HasValue) + { + int? totalBitrate = TargetTotalBitrate; + + double totalSeconds = RunTimeTicks.Value; + // Convert to ms + totalSeconds /= 10000; + // Convert to seconds + totalSeconds /= 1000; + + return totalBitrate.HasValue ? + Convert.ToInt64(totalBitrate.Value * totalSeconds) : + null; } return null; } + } - public string ToUrl(string baseUrl, string? accessToken) + /// + /// Gets the target video bitrate of the output stream. + /// + /// The video bitrate. + public int? TargetVideoBitrate + { + get { - ArgumentException.ThrowIfNullOrEmpty(baseUrl); + var stream = TargetVideoStream; - var list = new List(); - foreach (NameValuePair pair in BuildParams(this, accessToken)) + return VideoBitrate.HasValue && !IsDirectStream + ? VideoBitrate + : stream?.BitRate; + } + } + + /// + /// Gets the target timestamp of the output stream. + /// + /// The target timestamp. + public TransportStreamTimestamp TargetTimestamp + { + get + { + var defaultValue = string.Equals(Container, "m2ts", StringComparison.OrdinalIgnoreCase) + ? TransportStreamTimestamp.Valid + : TransportStreamTimestamp.None; + + return !IsDirectStream + ? defaultValue + : MediaSource is null ? defaultValue : MediaSource.Timestamp ?? TransportStreamTimestamp.None; + } + } + + /// + /// Gets the target total bitrate of the output stream. + /// + /// The target total bitrate. + public int? TargetTotalBitrate => (TargetAudioBitrate ?? 0) + (TargetVideoBitrate ?? 0); + + /// + /// Gets a value indicating whether the output stream is anamorphic. + /// + public bool? IsTargetAnamorphic + { + get + { + if (IsDirectStream) { - if (string.IsNullOrEmpty(pair.Value)) - { - continue; - } - - // Try to keep the url clean by omitting defaults - if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal); - - list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); + return TargetVideoStream?.IsAnamorphic; } - string queryString = string.Join('&', list); + return false; + } + } - return GetUrl(baseUrl, queryString); + /// + /// Gets a value indicating whether the output stream is interlaced. + /// + public bool? IsTargetInterlaced + { + get + { + if (IsDirectStream) + { + return TargetVideoStream?.IsInterlaced; + } + + var targetVideoCodecs = TargetVideoCodec; + var videoCodec = targetVideoCodecs.Count == 0 ? null : targetVideoCodecs[0]; + if (!string.IsNullOrEmpty(videoCodec)) + { + if (string.Equals(GetOption(videoCodec, "deinterlace"), "true", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return TargetVideoStream?.IsInterlaced; + } + } + + /// + /// Gets a value indicating whether the output stream is AVC. + /// + public bool? IsTargetAVC + { + get + { + if (IsDirectStream) + { + return TargetVideoStream?.IsAVC; + } + + return true; + } + } + + /// + /// Gets the target width of the output stream. + /// + /// The target width. + public int? TargetWidth + { + get + { + var videoStream = TargetVideoStream; + + if (videoStream is not null && videoStream.Width.HasValue && videoStream.Height.HasValue) + { + ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); + + size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); + + return size.Width; + } + + return MaxWidth; + } + } + + /// + /// Gets the target height of the output stream. + /// + /// The target height. + public int? TargetHeight + { + get + { + var videoStream = TargetVideoStream; + + if (videoStream is not null && videoStream.Width.HasValue && videoStream.Height.HasValue) + { + ImageDimensions size = new ImageDimensions(videoStream.Width.Value, videoStream.Height.Value); + + size = DrawingUtils.Resize(size, 0, 0, MaxWidth ?? 0, MaxHeight ?? 0); + + return size.Height; + } + + return MaxHeight; + } + } + + /// + /// Gets the target video stream count of the output stream. + /// + /// The target video stream count. + public int? TargetVideoStreamCount + { + get + { + if (IsDirectStream) + { + return GetMediaStreamCount(MediaStreamType.Video, int.MaxValue); + } + + return GetMediaStreamCount(MediaStreamType.Video, 1); + } + } + + /// + /// Gets the target audio stream count of the output stream. + /// + /// The target audio stream count. + public int? TargetAudioStreamCount + { + get + { + if (IsDirectStream) + { + return GetMediaStreamCount(MediaStreamType.Audio, int.MaxValue); + } + + return GetMediaStreamCount(MediaStreamType.Audio, 1); + } + } + + /// + /// Sets a stream option. + /// + /// The qualifier. + /// The name. + /// The value. + public void SetOption(string? qualifier, string name, string value) + { + if (string.IsNullOrEmpty(qualifier)) + { + SetOption(name, value); + } + else + { + SetOption(qualifier + "-" + name, value); + } + } + + /// + /// Sets a stream option. + /// + /// The name. + /// The value. + public void SetOption(string name, string value) + { + StreamOptions[name] = value; + } + + /// + /// Gets a stream option. + /// + /// The qualifier. + /// The name. + /// The value. + public string? GetOption(string? qualifier, string name) + { + var value = GetOption(qualifier + "-" + name); + + if (string.IsNullOrEmpty(value)) + { + value = GetOption(name); } - private string GetUrl(string baseUrl, string queryString) + return value; + } + + /// + /// Gets a stream option. + /// + /// The name. + /// The value. + public string? GetOption(string name) + { + if (StreamOptions.TryGetValue(name, out var value)) { - ArgumentException.ThrowIfNullOrEmpty(baseUrl); + return value; + } - string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; + return null; + } - baseUrl = baseUrl.TrimEnd('/'); + /// + /// Returns this output stream URL for this class. + /// + /// The base Url. + /// The access Token. + /// A querystring representation of this object. + public string ToUrl(string baseUrl, string? accessToken) + { + ArgumentException.ThrowIfNullOrEmpty(baseUrl); - if (MediaType == DlnaProfileType.Audio) + List list = []; + foreach (NameValuePair pair in BuildParams(this, accessToken)) + { + if (string.IsNullOrEmpty(pair.Value)) { - if (SubProtocol == MediaStreamProtocol.hls) - { - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); - } - - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + continue; } + // Try to keep the url clean by omitting defaults + if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal); + + list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); + } + + string queryString = string.Join('&', list); + + return GetUrl(baseUrl, queryString); + } + + private string GetUrl(string baseUrl, string queryString) + { + ArgumentException.ThrowIfNullOrEmpty(baseUrl); + + string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; + + baseUrl = baseUrl.TrimEnd('/'); + + if (MediaType == DlnaProfileType.Audio) + { if (SubProtocol == MediaStreamProtocol.hls) { - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); } - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); } - private static IEnumerable BuildParams(StreamInfo item, string? accessToken) + if (SubProtocol == MediaStreamProtocol.hls) { - var list = new List(); - - string audioCodecs = item.AudioCodecs.Length == 0 ? - string.Empty : - string.Join(',', item.AudioCodecs); - - string videoCodecs = item.VideoCodecs.Length == 0 ? - string.Empty : - string.Join(',', item.VideoCodecs); - - list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); - list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); - list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); - list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - list.Add(new NameValuePair("VideoCodec", videoCodecs)); - list.Add(new NameValuePair("AudioCodec", audioCodecs)); - list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - long startPositionTicks = item.StartPositionTicks; - - if (item.SubProtocol == MediaStreamProtocol.hls) - { - list.Add(new NameValuePair("StartTimeTicks", string.Empty)); - } - else - { - list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); - } - - list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); - list.Add(new NameValuePair("api_key", accessToken ?? string.Empty)); - - string? liveStreamId = item.MediaSource?.LiveStreamId; - list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); - - list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); - - if (!item.IsDirectStream) - { - if (item.RequireNonAnamorphic) - { - list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - if (item.EnableSubtitlesInManifest) - { - list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.EnableMpegtsM2TsMode) - { - list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.EstimateContentLength) - { - list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) - { - list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); - } - - if (item.CopyTimestamps) - { - list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - - list.Add(new NameValuePair("EnableAudioVbrEncoding", item.EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty)); - - string subtitleCodecs = item.SubtitleCodecs.Length == 0 ? - string.Empty : - string.Join(",", item.SubtitleCodecs); - - list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); - - if (item.SubProtocol == MediaStreamProtocol.hls) - { - list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); - - if (item.SegmentLength.HasValue) - { - list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); - } - - if (item.MinSegments.HasValue) - { - list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); - } - - list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); - } - - foreach (var pair in item.StreamOptions) - { - if (string.IsNullOrEmpty(pair.Value)) - { - continue; - } - - // strip spaces to avoid having to encode h264 profile names - list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal))); - } - - if (!item.IsDirectStream) - { - list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString())); - } - - return list; + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); } - public IEnumerable GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string? accessToken) + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + } + + private static List BuildParams(StreamInfo item, string? accessToken) + { + List list = []; + + string audioCodecs = item.AudioCodecs.Count == 0 ? + string.Empty : + string.Join(',', item.AudioCodecs); + + string videoCodecs = item.VideoCodecs.Count == 0 ? + string.Empty : + string.Join(',', item.VideoCodecs); + + list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); + list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); + list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); + list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + list.Add(new NameValuePair("VideoCodec", videoCodecs)); + list.Add(new NameValuePair("AudioCodec", audioCodecs)); + list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && (item.AlwaysBurnInSubtitleWhenTranscoding || item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + long startPositionTicks = item.StartPositionTicks; + + if (item.SubProtocol == MediaStreamProtocol.hls) { - return GetSubtitleProfiles(transcoderSupport, includeSelectedTrackOnly, false, baseUrl, accessToken); + list.Add(new NameValuePair("StartTimeTicks", string.Empty)); + } + else + { + list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); } - public IEnumerable GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string? accessToken) + list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); + list.Add(new NameValuePair("api_key", accessToken ?? string.Empty)); + + string? liveStreamId = item.MediaSource?.LiveStreamId; + list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); + + list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); + + if (!item.IsDirectStream) { - if (MediaSource is null) + if (item.RequireNonAnamorphic) { - return Enumerable.Empty(); + list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - var list = new List(); + list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - // HLS will preserve timestamps so we can just grab the full subtitle stream - long startPositionTicks = SubProtocol == MediaStreamProtocol.hls - ? 0 - : (PlayMethod == PlayMethod.Transcode && !CopyTimestamps ? StartPositionTicks : 0); - - // First add the selected track - if (SubtitleStreamIndex.HasValue) + if (item.EnableSubtitlesInManifest) { - foreach (var stream in MediaSource.MediaStreams) - { - if (stream.Type == MediaStreamType.Subtitle && stream.Index == SubtitleStreamIndex.Value) - { - AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); - } - } + list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - if (!includeSelectedTrackOnly) + if (item.EnableMpegtsM2TsMode) { - foreach (var stream in MediaSource.MediaStreams) - { - if (stream.Type == MediaStreamType.Subtitle && (!SubtitleStreamIndex.HasValue || stream.Index != SubtitleStreamIndex.Value)) - { - AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); - } - } + list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - return list; + if (item.EstimateContentLength) + { + list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) + { + list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); + } + + if (item.CopyTimestamps) + { + list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + + list.Add(new NameValuePair("EnableAudioVbrEncoding", item.EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - private void AddSubtitleProfiles(List list, MediaStream stream, ITranscoderSupport transcoderSupport, bool enableAllProfiles, string baseUrl, string? accessToken, long startPositionTicks) + list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty)); + + string subtitleCodecs = item.SubtitleCodecs.Count == 0 ? + string.Empty : + string.Join(",", item.SubtitleCodecs); + + list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); + + if (item.SubProtocol == MediaStreamProtocol.hls) { - if (enableAllProfiles) + list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); + + if (item.SegmentLength.HasValue) { - foreach (var profile in DeviceProfile.SubtitleProfiles) + list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); + } + + if (item.MinSegments.HasValue) + { + list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); + } + + list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); + } + + foreach (var pair in item.StreamOptions) + { + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } + + // strip spaces to avoid having to encode h264 profile names + list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal))); + } + + if (!item.IsDirectStream) + { + list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString())); + } + + return list; + } + + /// + /// Gets the subtitle profiles. + /// + /// The transcoder support. + /// If only the selected track should be included. + /// The base URL. + /// The access token. + /// The of the profiles. + public IEnumerable GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string? accessToken) + { + return GetSubtitleProfiles(transcoderSupport, includeSelectedTrackOnly, false, baseUrl, accessToken); + } + + /// + /// Gets the subtitle profiles. + /// + /// The transcoder support. + /// If only the selected track should be included. + /// If all profiles are enabled. + /// The base URL. + /// The access token. + /// The of the profiles. + public IEnumerable GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string? accessToken) + { + if (MediaSource is null) + { + return []; + } + + List list = []; + + // HLS will preserve timestamps so we can just grab the full subtitle stream + long startPositionTicks = SubProtocol == MediaStreamProtocol.hls + ? 0 + : (PlayMethod == PlayMethod.Transcode && !CopyTimestamps ? StartPositionTicks : 0); + + // First add the selected track + if (SubtitleStreamIndex.HasValue) + { + foreach (var stream in MediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Subtitle && stream.Index == SubtitleStreamIndex.Value) { - var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new[] { profile }, transcoderSupport); - if (info is not null) - { - list.Add(info); - } + AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); } } - else + } + + if (!includeSelectedTrackOnly) + { + foreach (var stream in MediaSource.MediaStreams) { - var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, DeviceProfile.SubtitleProfiles, transcoderSupport); + if (stream.Type == MediaStreamType.Subtitle && (!SubtitleStreamIndex.HasValue || stream.Index != SubtitleStreamIndex.Value)) + { + AddSubtitleProfiles(list, stream, transcoderSupport, enableAllProfiles, baseUrl, accessToken, startPositionTicks); + } + } + } + + return list; + } + + private void AddSubtitleProfiles(List list, MediaStream stream, ITranscoderSupport transcoderSupport, bool enableAllProfiles, string baseUrl, string? accessToken, long startPositionTicks) + { + if (enableAllProfiles) + { + foreach (var profile in DeviceProfile.SubtitleProfiles) + { + var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new[] { profile }, transcoderSupport); if (info is not null) { list.Add(info); } } } - - private SubtitleStreamInfo? GetSubtitleStreamInfo(MediaStream stream, string baseUrl, string? accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles, ITranscoderSupport transcoderSupport) + else { - if (MediaSource is null) + var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, DeviceProfile.SubtitleProfiles, transcoderSupport); + if (info is not null) { - return null; + list.Add(info); } + } + } - var subtitleProfile = StreamBuilder.GetSubtitleProfile(MediaSource, stream, subtitleProfiles, PlayMethod, transcoderSupport, Container, SubProtocol); - var info = new SubtitleStreamInfo - { - IsForced = stream.IsForced, - Language = stream.Language, - Name = stream.Language ?? "Unknown", - Format = subtitleProfile.Format, - Index = stream.Index, - DeliveryMethod = subtitleProfile.Method, - DisplayTitle = stream.DisplayTitle - }; + private SubtitleStreamInfo? GetSubtitleStreamInfo(MediaStream stream, string baseUrl, string? accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles, ITranscoderSupport transcoderSupport) + { + if (MediaSource is null) + { + return null; + } - if (info.DeliveryMethod == SubtitleDeliveryMethod.External) + var subtitleProfile = StreamBuilder.GetSubtitleProfile(MediaSource, stream, subtitleProfiles, PlayMethod, transcoderSupport, Container, SubProtocol); + var info = new SubtitleStreamInfo + { + IsForced = stream.IsForced, + Language = stream.Language, + Name = stream.Language ?? "Unknown", + Format = subtitleProfile.Format, + Index = stream.Index, + DeliveryMethod = subtitleProfile.Method, + DisplayTitle = stream.DisplayTitle + }; + + if (info.DeliveryMethod == SubtitleDeliveryMethod.External) + { + if (MediaSource.Protocol == MediaProtocol.File || !string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) || !stream.IsExternal) { - if (MediaSource.Protocol == MediaProtocol.File || !string.Equals(stream.Codec, subtitleProfile.Format, StringComparison.OrdinalIgnoreCase) || !stream.IsExternal) + info.Url = string.Format( + CultureInfo.InvariantCulture, + "{0}/Videos/{1}/{2}/Subtitles/{3}/{4}/Stream.{5}", + baseUrl, + ItemId, + MediaSourceId, + stream.Index.ToString(CultureInfo.InvariantCulture), + startPositionTicks.ToString(CultureInfo.InvariantCulture), + subtitleProfile.Format); + + if (!string.IsNullOrEmpty(accessToken)) { - info.Url = string.Format( - CultureInfo.InvariantCulture, - "{0}/Videos/{1}/{2}/Subtitles/{3}/{4}/Stream.{5}", - baseUrl, - ItemId, - MediaSourceId, - stream.Index.ToString(CultureInfo.InvariantCulture), - startPositionTicks.ToString(CultureInfo.InvariantCulture), - subtitleProfile.Format); - - if (!string.IsNullOrEmpty(accessToken)) - { - info.Url += "?api_key=" + accessToken; - } - - info.IsExternalUrl = false; + info.Url += "?api_key=" + accessToken; } - else - { - info.Url = stream.Path; - info.IsExternalUrl = true; - } - } - return info; + info.IsExternalUrl = false; + } + else + { + info.Url = stream.Path; + info.IsExternalUrl = true; + } } - public int? GetTargetVideoBitDepth(string? codec) + return info; + } + + /// + /// Gets the target video bit depth. + /// + /// The codec. + /// The target video bit depth. + public int? GetTargetVideoBitDepth(string? codec) + { + var value = GetOption(codec, "videobitdepth"); + + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { - var value = GetOption(codec, "videobitdepth"); - - if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - - return null; + return result; } - public int? GetTargetAudioBitDepth(string? codec) + return null; + } + + /// + /// Gets the target audio bit depth. + /// + /// The codec. + /// The target audio bit depth. + public int? GetTargetAudioBitDepth(string? codec) + { + var value = GetOption(codec, "audiobitdepth"); + + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { - var value = GetOption(codec, "audiobitdepth"); - - if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - - return null; + return result; } - public double? GetTargetVideoLevel(string? codec) + return null; + } + + /// + /// Gets the target video level. + /// + /// The codec. + /// The target video level. + public double? GetTargetVideoLevel(string? codec) + { + var value = GetOption(codec, "level"); + + if (double.TryParse(value, CultureInfo.InvariantCulture, out var result)) { - var value = GetOption(codec, "level"); - - if (double.TryParse(value, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - - return null; + return result; } - public int? GetTargetRefFrames(string? codec) + return null; + } + + /// + /// Gets the target reference frames. + /// + /// The codec. + /// The target reference frames. + public int? GetTargetRefFrames(string? codec) + { + var value = GetOption(codec, "maxrefframes"); + + if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) { - var value = GetOption(codec, "maxrefframes"); - - if (int.TryParse(value, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - - return null; + return result; } - public int? GetTargetAudioChannels(string? codec) + return null; + } + + /// + /// Gets the target audio channels. + /// + /// The codec. + /// The target audio channels. + public int? GetTargetAudioChannels(string? codec) + { + var defaultValue = GlobalMaxAudioChannels ?? TranscodingMaxAudioChannels; + + var value = GetOption(codec, "audiochannels"); + if (string.IsNullOrEmpty(value)) { - var defaultValue = GlobalMaxAudioChannels ?? TranscodingMaxAudioChannels; - - var value = GetOption(codec, "audiochannels"); - if (string.IsNullOrEmpty(value)) - { - return defaultValue; - } - - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) - { - return Math.Min(result, defaultValue ?? result); - } - return defaultValue; } - private int? GetMediaStreamCount(MediaStreamType type, int limit) + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) { - var count = MediaSource?.GetStreamCount(type); - - if (count.HasValue) - { - count = Math.Min(count.Value, limit); - } - - return count; + return Math.Min(result, defaultValue ?? result); } + + return defaultValue; + } + + /// + /// Gets the media stream count. + /// + /// The type. + /// The limit. + /// The media stream count. + private int? GetMediaStreamCount(MediaStreamType type, int limit) + { + var count = MediaSource?.GetStreamCount(type); + + if (count.HasValue) + { + count = Math.Min(count.Value, limit); + } + + return count; } } diff --git a/MediaBrowser.Model/Dlna/SubtitleProfile.cs b/MediaBrowser.Model/Dlna/SubtitleProfile.cs index 9ebde25ffe..1879f2dd23 100644 --- a/MediaBrowser.Model/Dlna/SubtitleProfile.cs +++ b/MediaBrowser.Model/Dlna/SubtitleProfile.cs @@ -1,48 +1,62 @@ #nullable disable -#pragma warning disable CS1591 -using System; using System.Xml.Serialization; -using Jellyfin.Extensions; +using MediaBrowser.Model.Extensions; -namespace MediaBrowser.Model.Dlna +namespace MediaBrowser.Model.Dlna; + +/// +/// A class for subtitle profile information. +/// +public class SubtitleProfile { - public class SubtitleProfile + /// + /// Gets or sets the format. + /// + [XmlAttribute("format")] + public string Format { get; set; } + + /// + /// Gets or sets the delivery method. + /// + [XmlAttribute("method")] + public SubtitleDeliveryMethod Method { get; set; } + + /// + /// Gets or sets the DIDL mode. + /// + [XmlAttribute("didlMode")] + public string DidlMode { get; set; } + + /// + /// Gets or sets the language. + /// + [XmlAttribute("language")] + public string Language { get; set; } + + /// + /// Gets or sets the container. + /// + [XmlAttribute("container")] + public string Container { get; set; } + + /// + /// Checks if a language is supported. + /// + /// The language to check for support. + /// true if supported. + public bool SupportsLanguage(string subLanguage) { - [XmlAttribute("format")] - public string Format { get; set; } - - [XmlAttribute("method")] - public SubtitleDeliveryMethod Method { get; set; } - - [XmlAttribute("didlMode")] - public string DidlMode { get; set; } - - [XmlAttribute("language")] - public string Language { get; set; } - - [XmlAttribute("container")] - public string Container { get; set; } - - public string[] GetLanguages() + if (string.IsNullOrEmpty(Language)) { - return ContainerProfile.SplitValue(Language); + return true; } - public bool SupportsLanguage(string subLanguage) + if (string.IsNullOrEmpty(subLanguage)) { - if (string.IsNullOrEmpty(Language)) - { - return true; - } - - if (string.IsNullOrEmpty(subLanguage)) - { - subLanguage = "und"; - } - - var languages = GetLanguages(); - return languages.Length == 0 || languages.Contains(subLanguage, StringComparison.OrdinalIgnoreCase); + subLanguage = "und"; } + + return ContainerHelper.ContainsContainer(Language, subLanguage); } } diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs index a556799deb..5a9fa22ae4 100644 --- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs +++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs @@ -1,82 +1,130 @@ -#pragma warning disable CS1591 - -using System; using System.ComponentModel; using System.Xml.Serialization; using Jellyfin.Data.Enums; -namespace MediaBrowser.Model.Dlna +namespace MediaBrowser.Model.Dlna; + +/// +/// A class for transcoding profile information. +/// +public class TranscodingProfile { - public class TranscodingProfile + /// + /// Initializes a new instance of the class. + /// + public TranscodingProfile() { - public TranscodingProfile() - { - Conditions = Array.Empty(); - } - - [XmlAttribute("container")] - public string Container { get; set; } = string.Empty; - - [XmlAttribute("type")] - public DlnaProfileType Type { get; set; } - - [XmlAttribute("videoCodec")] - public string VideoCodec { get; set; } = string.Empty; - - [XmlAttribute("audioCodec")] - public string AudioCodec { get; set; } = string.Empty; - - [XmlAttribute("protocol")] - public MediaStreamProtocol Protocol { get; set; } = MediaStreamProtocol.http; - - [DefaultValue(false)] - [XmlAttribute("estimateContentLength")] - public bool EstimateContentLength { get; set; } - - [DefaultValue(false)] - [XmlAttribute("enableMpegtsM2TsMode")] - public bool EnableMpegtsM2TsMode { get; set; } - - [DefaultValue(TranscodeSeekInfo.Auto)] - [XmlAttribute("transcodeSeekInfo")] - public TranscodeSeekInfo TranscodeSeekInfo { get; set; } - - [DefaultValue(false)] - [XmlAttribute("copyTimestamps")] - public bool CopyTimestamps { get; set; } - - [DefaultValue(EncodingContext.Streaming)] - [XmlAttribute("context")] - public EncodingContext Context { get; set; } - - [DefaultValue(false)] - [XmlAttribute("enableSubtitlesInManifest")] - public bool EnableSubtitlesInManifest { get; set; } - - [XmlAttribute("maxAudioChannels")] - public string? MaxAudioChannels { get; set; } - - [DefaultValue(0)] - [XmlAttribute("minSegments")] - public int MinSegments { get; set; } - - [DefaultValue(0)] - [XmlAttribute("segmentLength")] - public int SegmentLength { get; set; } - - [DefaultValue(false)] - [XmlAttribute("breakOnNonKeyFrames")] - public bool BreakOnNonKeyFrames { get; set; } - - public ProfileCondition[] Conditions { get; set; } - - [DefaultValue(true)] - [XmlAttribute("enableAudioVbrEncoding")] - public bool EnableAudioVbrEncoding { get; set; } = true; - - public string[] GetAudioCodecs() - { - return ContainerProfile.SplitValue(AudioCodec); - } + Conditions = []; } + + /// + /// Gets or sets the container. + /// + [XmlAttribute("container")] + public string Container { get; set; } = string.Empty; + + /// + /// Gets or sets the DLNA profile type. + /// + [XmlAttribute("type")] + public DlnaProfileType Type { get; set; } + + /// + /// Gets or sets the video codec. + /// + [XmlAttribute("videoCodec")] + public string VideoCodec { get; set; } = string.Empty; + + /// + /// Gets or sets the audio codec. + /// + [XmlAttribute("audioCodec")] + public string AudioCodec { get; set; } = string.Empty; + + /// + /// Gets or sets the protocol. + /// + [XmlAttribute("protocol")] + public MediaStreamProtocol Protocol { get; set; } = MediaStreamProtocol.http; + + /// + /// Gets or sets a value indicating whether the content length should be estimated. + /// + [DefaultValue(false)] + [XmlAttribute("estimateContentLength")] + public bool EstimateContentLength { get; set; } + + /// + /// Gets or sets a value indicating whether M2TS mode is enabled. + /// + [DefaultValue(false)] + [XmlAttribute("enableMpegtsM2TsMode")] + public bool EnableMpegtsM2TsMode { get; set; } + + /// + /// Gets or sets the transcoding seek info mode. + /// + [DefaultValue(TranscodeSeekInfo.Auto)] + [XmlAttribute("transcodeSeekInfo")] + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + /// + /// Gets or sets a value indicating whether timestamps should be copied. + /// + [DefaultValue(false)] + [XmlAttribute("copyTimestamps")] + public bool CopyTimestamps { get; set; } + + /// + /// Gets or sets the encoding context. + /// + [DefaultValue(EncodingContext.Streaming)] + [XmlAttribute("context")] + public EncodingContext Context { get; set; } + + /// + /// Gets or sets a value indicating whether subtitles are allowed in the manifest. + /// + [DefaultValue(false)] + [XmlAttribute("enableSubtitlesInManifest")] + public bool EnableSubtitlesInManifest { get; set; } + + /// + /// Gets or sets the maximum audio channels. + /// + [XmlAttribute("maxAudioChannels")] + public string? MaxAudioChannels { get; set; } + + /// + /// Gets or sets the minimum amount of segments. + /// + [DefaultValue(0)] + [XmlAttribute("minSegments")] + public int MinSegments { get; set; } + + /// + /// Gets or sets the segment length. + /// + [DefaultValue(0)] + [XmlAttribute("segmentLength")] + public int SegmentLength { get; set; } + + /// + /// Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported. + /// + [DefaultValue(false)] + [XmlAttribute("breakOnNonKeyFrames")] + public bool BreakOnNonKeyFrames { get; set; } + + /// + /// Gets or sets the profile conditions. + /// + public ProfileCondition[] Conditions { get; set; } + + /// + /// Gets or sets a value indicating whether variable bitrate encoding is supported. + /// + [DefaultValue(true)] + [XmlAttribute("enableAudioVbrEncoding")] + public bool EnableAudioVbrEncoding { get; set; } = true; } diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs similarity index 77% rename from Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs rename to MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs index c699c469d9..5963ed270d 100644 --- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs +++ b/MediaBrowser.Model/Dto/ClientCapabilitiesDto.cs @@ -1,13 +1,11 @@ -using System; using System.Collections.Generic; -using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Session; -namespace Jellyfin.Api.Models.SessionDtos; +namespace MediaBrowser.Model.Dto; /// /// Client capabilities dto. @@ -18,13 +16,13 @@ public class ClientCapabilitiesDto /// Gets or sets the list of playable media types. /// [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList PlayableMediaTypes { get; set; } = Array.Empty(); + public IReadOnlyList PlayableMediaTypes { get; set; } = []; /// /// Gets or sets the list of supported commands. /// [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList SupportedCommands { get; set; } = Array.Empty(); + public IReadOnlyList SupportedCommands { get; set; } = []; /// /// Gets or sets a value indicating whether session supports media control. @@ -51,18 +49,6 @@ public class ClientCapabilitiesDto /// public string? IconUrl { get; set; } -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - // TODO: Remove after 10.9 - [Obsolete("Unused")] - [DefaultValue(false)] - public bool? SupportsContentUploading { get; set; } = false; - - // TODO: Remove after 10.9 - [Obsolete("Unused")] - [DefaultValue(false)] - public bool? SupportsSync { get; set; } = false; -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member - /// /// Convert the dto to the full model. /// diff --git a/MediaBrowser.Model/Dto/DeviceInfoDto.cs b/MediaBrowser.Model/Dto/DeviceInfoDto.cs new file mode 100644 index 0000000000..ac7a731a90 --- /dev/null +++ b/MediaBrowser.Model/Dto/DeviceInfoDto.cs @@ -0,0 +1,83 @@ +using System; + +namespace MediaBrowser.Model.Dto; + +/// +/// A DTO representing device information. +/// +public class DeviceInfoDto +{ + /// + /// Initializes a new instance of the class. + /// + public DeviceInfoDto() + { + Capabilities = new ClientCapabilitiesDto(); + } + + /// + /// Gets or sets the name. + /// + /// The name. + public string? Name { get; set; } + + /// + /// Gets or sets the custom name. + /// + /// The custom name. + public string? CustomName { get; set; } + + /// + /// Gets or sets the access token. + /// + /// The access token. + public string? AccessToken { get; set; } + + /// + /// Gets or sets the identifier. + /// + /// The identifier. + public string? Id { get; set; } + + /// + /// Gets or sets the last name of the user. + /// + /// The last name of the user. + public string? LastUserName { get; set; } + + /// + /// Gets or sets the name of the application. + /// + /// The name of the application. + public string? AppName { get; set; } + + /// + /// Gets or sets the application version. + /// + /// The application version. + public string? AppVersion { get; set; } + + /// + /// Gets or sets the last user identifier. + /// + /// The last user identifier. + public Guid? LastUserId { get; set; } + + /// + /// Gets or sets the date last modified. + /// + /// The date last modified. + public DateTime? DateLastActivity { get; set; } + + /// + /// Gets or sets the capabilities. + /// + /// The capabilities. + public ClientCapabilitiesDto Capabilities { get; set; } + + /// + /// Gets or sets the icon URL. + /// + /// The icon URL. + public string? IconUrl { get; set; } +} diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs index 1c6037325b..eff2e09da1 100644 --- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs +++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using MediaBrowser.Model.Dlna; @@ -24,6 +25,7 @@ namespace MediaBrowser.Model.Dto SupportsDirectStream = true; SupportsDirectPlay = true; SupportsProbing = true; + UseMostCompatibleTranscodingProfile = false; } public MediaProtocol Protocol { get; set; } @@ -70,6 +72,9 @@ namespace MediaBrowser.Model.Dto public bool IsInfiniteStream { get; set; } + [DefaultValue(false)] + public bool UseMostCompatibleTranscodingProfile { get; set; } + public bool RequiresOpening { get; set; } public string OpenToken { get; set; } @@ -98,6 +103,8 @@ namespace MediaBrowser.Model.Dto public int? Bitrate { get; set; } + public int? FallbackMaxStreamingBitrate { get; set; } + public TransportStreamTimestamp? Timestamp { get; set; } public Dictionary RequiredHttpHeaders { get; set; } diff --git a/MediaBrowser.Model/Dto/PlaylistDto.cs b/MediaBrowser.Model/Dto/PlaylistDto.cs new file mode 100644 index 0000000000..d4de75a784 --- /dev/null +++ b/MediaBrowser.Model/Dto/PlaylistDto.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Model.Dto; + +/// +/// DTO for playlists. +/// +public class PlaylistDto +{ + /// + /// Gets or sets a value indicating whether the playlist is publicly readable. + /// + public bool OpenAccess { get; set; } + + /// + /// Gets or sets the share permissions. + /// + public required IReadOnlyList Shares { get; set; } + + /// + /// Gets or sets the item ids. + /// + public required IReadOnlyList ItemIds { get; set; } +} diff --git a/MediaBrowser.Model/Dto/SessionInfoDto.cs b/MediaBrowser.Model/Dto/SessionInfoDto.cs new file mode 100644 index 0000000000..2496c933a2 --- /dev/null +++ b/MediaBrowser.Model/Dto/SessionInfoDto.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Model.Dto; + +/// +/// Session info DTO. +/// +public class SessionInfoDto +{ + /// + /// Gets or sets the play state. + /// + /// The play state. + public PlayerStateInfo? PlayState { get; set; } + + /// + /// Gets or sets the additional users. + /// + /// The additional users. + public IReadOnlyList? AdditionalUsers { get; set; } + + /// + /// Gets or sets the client capabilities. + /// + /// The client capabilities. + public ClientCapabilitiesDto? Capabilities { get; set; } + + /// + /// Gets or sets the remote end point. + /// + /// The remote end point. + public string? RemoteEndPoint { get; set; } + + /// + /// Gets or sets the playable media types. + /// + /// The playable media types. + public IReadOnlyList PlayableMediaTypes { get; set; } = []; + + /// + /// Gets or sets the id. + /// + /// The id. + public string? Id { get; set; } + + /// + /// Gets or sets the user id. + /// + /// The user id. + public Guid UserId { get; set; } + + /// + /// Gets or sets the username. + /// + /// The username. + public string? UserName { get; set; } + + /// + /// Gets or sets the type of the client. + /// + /// The type of the client. + public string? Client { get; set; } + + /// + /// Gets or sets the last activity date. + /// + /// The last activity date. + public DateTime LastActivityDate { get; set; } + + /// + /// Gets or sets the last playback check in. + /// + /// The last playback check in. + public DateTime LastPlaybackCheckIn { get; set; } + + /// + /// Gets or sets the last paused date. + /// + /// The last paused date. + public DateTime? LastPausedDate { get; set; } + + /// + /// Gets or sets the name of the device. + /// + /// The name of the device. + public string? DeviceName { get; set; } + + /// + /// Gets or sets the type of the device. + /// + /// The type of the device. + public string? DeviceType { get; set; } + + /// + /// Gets or sets the now playing item. + /// + /// The now playing item. + public BaseItemDto? NowPlayingItem { get; set; } + + /// + /// Gets or sets the now viewing item. + /// + /// The now viewing item. + public BaseItemDto? NowViewingItem { get; set; } + + /// + /// Gets or sets the device id. + /// + /// The device id. + public string? DeviceId { get; set; } + + /// + /// Gets or sets the application version. + /// + /// The application version. + public string? ApplicationVersion { get; set; } + + /// + /// Gets or sets the transcoding info. + /// + /// The transcoding info. + public TranscodingInfo? TranscodingInfo { get; set; } + + /// + /// Gets or sets a value indicating whether this session is active. + /// + /// true if this session is active; otherwise, false. + public bool IsActive { get; set; } + + /// + /// Gets or sets a value indicating whether the session supports media control. + /// + /// true if this session supports media control; otherwise, false. + public bool SupportsMediaControl { get; set; } + + /// + /// Gets or sets a value indicating whether the session supports remote control. + /// + /// true if this session supports remote control; otherwise, false. + public bool SupportsRemoteControl { get; set; } + + /// + /// Gets or sets the now playing queue. + /// + /// The now playing queue. + public IReadOnlyList? NowPlayingQueue { get; set; } + + /// + /// Gets or sets the now playing queue full items. + /// + /// The now playing queue full items. + public IReadOnlyList? NowPlayingQueueFullItems { get; set; } + + /// + /// Gets or sets a value indicating whether the session has a custom device name. + /// + /// true if this session has a custom device name; otherwise, false. + public bool HasCustomDeviceName { get; set; } + + /// + /// Gets or sets the playlist item id. + /// + /// The splaylist item id. + public string? PlaylistItemId { get; set; } + + /// + /// Gets or sets the server id. + /// + /// The server id. + public string? ServerId { get; set; } + + /// + /// Gets or sets the user primary image tag. + /// + /// The user primary image tag. + public string? UserPrimaryImageTag { get; set; } + + /// + /// Gets or sets the supported commands. + /// + /// The supported commands. + public IReadOnlyList SupportedCommands { get; set; } = []; +} diff --git a/MediaBrowser.Model/Entities/DeinterlaceMethod.cs b/MediaBrowser.Model/Entities/DeinterlaceMethod.cs new file mode 100644 index 0000000000..d05aac4339 --- /dev/null +++ b/MediaBrowser.Model/Entities/DeinterlaceMethod.cs @@ -0,0 +1,19 @@ +#pragma warning disable SA1300 // Lowercase required for backwards compat. + +namespace MediaBrowser.Model.Entities; + +/// +/// Enum containing deinterlace methods. +/// +public enum DeinterlaceMethod +{ + /// + /// YADIF. + /// + yadif = 0, + + /// + /// BWDIF. + /// + bwdif = 1 +} diff --git a/MediaBrowser.Model/Entities/EncoderPreset.cs b/MediaBrowser.Model/Entities/EncoderPreset.cs new file mode 100644 index 0000000000..74c0714334 --- /dev/null +++ b/MediaBrowser.Model/Entities/EncoderPreset.cs @@ -0,0 +1,64 @@ +#pragma warning disable SA1300 // Lowercase required for backwards compat. + +namespace MediaBrowser.Model.Entities; + +/// +/// Enum containing encoder presets. +/// +public enum EncoderPreset +{ + /// + /// Auto preset. + /// + auto = 0, + + /// + /// Placebo preset. + /// + placebo = 1, + + /// + /// Veryslow preset. + /// + veryslow = 2, + + /// + /// Slower preset. + /// + slower = 3, + + /// + /// Slow preset. + /// + slow = 4, + + /// + /// Medium preset. + /// + medium = 5, + + /// + /// Fast preset. + /// + fast = 6, + + /// + /// Faster preset. + /// + faster = 7, + + /// + /// Veryfast preset. + /// + veryfast = 8, + + /// + /// Superfast preset. + /// + superfast = 9, + + /// + /// Ultrafast preset. + /// + ultrafast = 10 +} diff --git a/MediaBrowser.Model/Entities/HardwareAccelerationType.cs b/MediaBrowser.Model/Entities/HardwareAccelerationType.cs new file mode 100644 index 0000000000..198a2e00f6 --- /dev/null +++ b/MediaBrowser.Model/Entities/HardwareAccelerationType.cs @@ -0,0 +1,49 @@ +#pragma warning disable SA1300 // Lowercase required for backwards compat. + +namespace MediaBrowser.Model.Entities; + +/// +/// Enum containing hardware acceleration types. +/// +public enum HardwareAccelerationType +{ + /// + /// Software accelleration. + /// + none = 0, + + /// + /// AMD AMF. + /// + amf = 1, + + /// + /// Intel Quick Sync Video. + /// + qsv = 2, + + /// + /// NVIDIA NVENC. + /// + nvenc = 3, + + /// + /// Video4Linux2 V4L2M2M. + /// + v4l2m2m = 4, + + /// + /// Video Acceleration API (VAAPI). + /// + vaapi = 5, + + /// + /// Video ToolBox. + /// + videotoolbox = 6, + + /// + /// Rockchip Media Process Platform (RKMPP). + /// + rkmpp = 7 +} diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index a0e8c39bee..85c1f797b4 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -200,7 +200,8 @@ namespace MediaBrowser.Model.Entities || dvProfile == 5 || dvProfile == 7 || dvProfile == 8 - || dvProfile == 9)) + || dvProfile == 9 + || dvProfile == 10)) { var title = "Dolby Vision Profile " + dvProfile; @@ -525,6 +526,23 @@ namespace MediaBrowser.Model.Entities /// The real frame rate. public float? RealFrameRate { get; set; } + /// + /// Gets the framerate used as reference. + /// Prefer AverageFrameRate, if that is null or an unrealistic value + /// then fallback to RealFrameRate. + /// + /// The reference frame rate. + public float? ReferenceFrameRate + { + get + { + // In some cases AverageFrameRate for videos will be read as 1000fps even if it is not. + // This is probably due to a library compatability issue. + // See https://github.com/jellyfin/jellyfin/pull/12603#discussion_r1748044018 for more info. + return AverageFrameRate < 1000 ? AverageFrameRate : RealFrameRate; + } + } + /// /// Gets or sets the profile. /// @@ -760,7 +778,7 @@ namespace MediaBrowser.Model.Entities var blPresentFlag = BlPresentFlag == 1; var dvBlCompatId = DvBlSignalCompatibilityId; - var isDoViProfile = dvProfile == 5 || dvProfile == 7 || dvProfile == 8; + var isDoViProfile = dvProfile == 5 || dvProfile == 7 || dvProfile == 8 || dvProfile == 10; var isDoViFlag = rpuPresentFlag && blPresentFlag && (dvBlCompatId == 0 || dvBlCompatId == 1 || dvBlCompatId == 4 || dvBlCompatId == 2 || dvBlCompatId == 6); if ((isDoViProfile && isDoViFlag) @@ -783,6 +801,17 @@ namespace MediaBrowser.Model.Entities _ => (VideoRange.SDR, VideoRangeType.SDR) }, 7 => (VideoRange.HDR, VideoRangeType.HDR10), + 10 => dvBlCompatId switch + { + 0 => (VideoRange.HDR, VideoRangeType.DOVI), + 1 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10), + 2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR), + 4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG), + // While not in Dolby Spec, Profile 8 CCid 6 media are possible to create, and since CCid 6 stems from Bluray (Profile 7 originally) an HDR10 base layer is guaranteed to exist. + 6 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10), + // There is no other case to handle here as per Dolby Spec. Default case included for completeness and linting purposes + _ => (VideoRange.SDR, VideoRangeType.SDR) + }, _ => (VideoRange.SDR, VideoRangeType.SDR) }; } diff --git a/MediaBrowser.Model/Entities/TonemappingAlgorithm.cs b/MediaBrowser.Model/Entities/TonemappingAlgorithm.cs new file mode 100644 index 0000000000..488006e0bc --- /dev/null +++ b/MediaBrowser.Model/Entities/TonemappingAlgorithm.cs @@ -0,0 +1,49 @@ +#pragma warning disable SA1300 // Lowercase required for backwards compat. + +namespace MediaBrowser.Model.Entities; + +/// +/// Enum containing tonemapping algorithms. +/// +public enum TonemappingAlgorithm +{ + /// + /// None. + /// + none = 0, + + /// + /// Clip. + /// + clip = 1, + + /// + /// Linear. + /// + linear = 2, + + /// + /// Gamma. + /// + gamma = 3, + + /// + /// Reinhard. + /// + reinhard = 4, + + /// + /// Hable. + /// + hable = 5, + + /// + /// Mobius. + /// + mobius = 6, + + /// + /// BT2390. + /// + bt2390 = 7 +} diff --git a/MediaBrowser.Model/Entities/TonemappingMode.cs b/MediaBrowser.Model/Entities/TonemappingMode.cs new file mode 100644 index 0000000000..e10a0b4ad1 --- /dev/null +++ b/MediaBrowser.Model/Entities/TonemappingMode.cs @@ -0,0 +1,34 @@ +#pragma warning disable SA1300 // Lowercase required for backwards compat. + +namespace MediaBrowser.Model.Entities; + +/// +/// Enum containing tonemapping modes. +/// +public enum TonemappingMode +{ + /// + /// Auto. + /// + auto = 0, + + /// + /// Max. + /// + max = 1, + + /// + /// RGB. + /// + rgb = 2, + + /// + /// Lum. + /// + lum = 3, + + /// + /// ITP. + /// + itp = 4 +} diff --git a/MediaBrowser.Model/Entities/TonemappingRange.cs b/MediaBrowser.Model/Entities/TonemappingRange.cs new file mode 100644 index 0000000000..b1446b81c6 --- /dev/null +++ b/MediaBrowser.Model/Entities/TonemappingRange.cs @@ -0,0 +1,24 @@ +#pragma warning disable SA1300 // Lowercase required for backwards compat. + +namespace MediaBrowser.Model.Entities; + +/// +/// Enum containing tonemapping ranges. +/// +public enum TonemappingRange +{ + /// + /// Auto. + /// + auto = 0, + + /// + /// TV. + /// + tv = 1, + + /// + /// PC. + /// + pc = 2 +} diff --git a/MediaBrowser.Model/Extensions/ContainerHelper.cs b/MediaBrowser.Model/Extensions/ContainerHelper.cs new file mode 100644 index 0000000000..c86328ba68 --- /dev/null +++ b/MediaBrowser.Model/Extensions/ContainerHelper.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Extensions; + +namespace MediaBrowser.Model.Extensions; + +/// +/// Defines the class. +/// +public static class ContainerHelper +{ + /// + /// Compares two containers, returning true if an item in exists + /// in . + /// + /// The comma-delimited string being searched. + /// If the parameter begins with the - character, the operation is reversed. + /// The comma-delimited string being matched. + /// The result of the operation. + public static bool ContainsContainer(string? profileContainers, string? inputContainer) + { + var isNegativeList = false; + if (profileContainers != null && profileContainers.StartsWith('-')) + { + isNegativeList = true; + profileContainers = profileContainers[1..]; + } + + return ContainsContainer(profileContainers, isNegativeList, inputContainer); + } + + /// + /// Compares two containers, returning true if an item in exists + /// in . + /// + /// The comma-delimited string being searched. + /// If the parameter begins with the - character, the operation is reversed. + /// The comma-delimited string being matched. + /// The result of the operation. + public static bool ContainsContainer(string? profileContainers, ReadOnlySpan inputContainer) + { + var isNegativeList = false; + if (profileContainers != null && profileContainers.StartsWith('-')) + { + isNegativeList = true; + profileContainers = profileContainers[1..]; + } + + return ContainsContainer(profileContainers, isNegativeList, inputContainer); + } + + /// + /// Compares two containers, returning if an item in + /// does not exist in . + /// + /// The comma-delimited string being searched. + /// The boolean result to return if a match is not found. + /// The comma-delimited string being matched. + /// The result of the operation. + public static bool ContainsContainer(string? profileContainers, bool isNegativeList, string? inputContainer) + { + if (string.IsNullOrEmpty(inputContainer)) + { + return isNegativeList; + } + + return ContainsContainer(profileContainers, isNegativeList, inputContainer.AsSpan()); + } + + /// + /// Compares two containers, returning if an item in + /// does not exist in . + /// + /// The comma-delimited string being searched. + /// The boolean result to return if a match is not found. + /// The comma-delimited string being matched. + /// The result of the operation. + public static bool ContainsContainer(string? profileContainers, bool isNegativeList, ReadOnlySpan inputContainer) + { + if (string.IsNullOrEmpty(profileContainers)) + { + // Empty profiles always support all containers/codecs. + return true; + } + + var allInputContainers = inputContainer.Split(','); + var allProfileContainers = profileContainers.SpanSplit(','); + foreach (var container in allInputContainers) + { + if (!container.IsEmpty) + { + foreach (var profile in allProfileContainers) + { + if (!profile.IsEmpty && container.Equals(profile, StringComparison.OrdinalIgnoreCase)) + { + return !isNegativeList; + } + } + } + } + + return isNegativeList; + } + + /// + /// Compares two containers, returning if an item in + /// does not exist in . + /// + /// The profile containers being matched searched. + /// The boolean result to return if a match is not found. + /// The comma-delimited string being matched. + /// The result of the operation. + public static bool ContainsContainer(IReadOnlyList? profileContainers, bool isNegativeList, string inputContainer) + { + if (profileContainers is null) + { + // Empty profiles always support all containers/codecs. + return true; + } + + var allInputContainers = Split(inputContainer); + foreach (var container in allInputContainers) + { + foreach (var profile in profileContainers) + { + if (string.Equals(profile, container, StringComparison.OrdinalIgnoreCase)) + { + return !isNegativeList; + } + } + } + + return isNegativeList; + } + + /// + /// Splits and input string. + /// + /// The input string. + /// The result of the operation. + public static string[] Split(string? input) + { + return input?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? []; + } +} diff --git a/MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs b/MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs new file mode 100644 index 0000000000..4a814f22a3 --- /dev/null +++ b/MediaBrowser.Model/Extensions/LibraryOptionsExtension.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Model.Extensions; + +/// +/// Extensions for . +/// +public static class LibraryOptionsExtension +{ + /// + /// Get the custom tag delimiters. + /// + /// This LibraryOptions. + /// CustomTagDelimiters in char[]. + public static char[] GetCustomTagDelimiters(this LibraryOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return options.CustomTagDelimiters.Select(x => + { + var isChar = char.TryParse(x, out var c); + if (isChar) + { + return c; + } + + return null; + }).Where(x => x is not null).Select(x => x!.Value).ToArray(); + } +} diff --git a/MediaBrowser.Model/IO/IFileSystem.cs b/MediaBrowser.Model/IO/IFileSystem.cs index ec381d4231..2085328ddc 100644 --- a/MediaBrowser.Model/IO/IFileSystem.cs +++ b/MediaBrowser.Model/IO/IFileSystem.cs @@ -33,6 +33,13 @@ namespace MediaBrowser.Model.IO string MakeAbsolutePath(string folderPath, string filePath); + /// + /// Moves a directory to a new location. + /// + /// Source directory. + /// Destination directory. + void MoveDirectory(string source, string destination); + /// /// Returns a object for the specified file or directory path. /// diff --git a/MediaBrowser.Model/LiveTv/TunerHostInfo.cs b/MediaBrowser.Model/LiveTv/TunerHostInfo.cs index a832169c2a..a355387b1a 100644 --- a/MediaBrowser.Model/LiveTv/TunerHostInfo.cs +++ b/MediaBrowser.Model/LiveTv/TunerHostInfo.cs @@ -9,6 +9,9 @@ namespace MediaBrowser.Model.LiveTv { AllowHWTranscoding = true; IgnoreDts = true; + AllowStreamSharing = true; + AllowFmp4TranscodingContainer = false; + FallbackMaxStreamingBitrate = 30000000; } public string Id { get; set; } @@ -25,6 +28,12 @@ namespace MediaBrowser.Model.LiveTv public bool AllowHWTranscoding { get; set; } + public bool AllowFmp4TranscodingContainer { get; set; } + + public bool AllowStreamSharing { get; set; } + + public int FallbackMaxStreamingBitrate { get; set; } + public bool EnableStreamLooping { get; set; } public string Source { get; set; } diff --git a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs index 24eab1a744..92f467eb08 100644 --- a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs +++ b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs @@ -13,6 +13,7 @@ namespace MediaBrowser.Model.MediaInfo { EnableDirectPlay = true; EnableDirectStream = true; + AlwaysBurnInSubtitleWhenTranscoding = false; DirectPlayProtocols = new MediaProtocol[] { MediaProtocol.Http }; } @@ -40,6 +41,8 @@ namespace MediaBrowser.Model.MediaInfo public bool EnableDirectStream { get; set; } + public bool AlwaysBurnInSubtitleWhenTranscoding { get; set; } + public IReadOnlyList DirectPlayProtocols { get; set; } } } diff --git a/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs new file mode 100644 index 0000000000..8c1f44de8c --- /dev/null +++ b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs @@ -0,0 +1,14 @@ +using System; + +namespace MediaBrowser.Model; + +/// +/// Model containing the arguments for enumerating the requested media item. +/// +public record MediaSegmentGenerationRequest +{ + /// + /// Gets the Id to the BaseItem the segments should be extracted from. + /// + public Guid ItemId { get; init; } +} diff --git a/MediaBrowser.Model/Session/HardwareEncodingType.cs b/MediaBrowser.Model/Session/HardwareEncodingType.cs deleted file mode 100644 index cf424fef53..0000000000 --- a/MediaBrowser.Model/Session/HardwareEncodingType.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace MediaBrowser.Model.Session -{ - /// - /// Enum HardwareEncodingType. - /// - public enum HardwareEncodingType - { - /// - /// AMD AMF. - /// - AMF = 0, - - /// - /// Intel Quick Sync Video. - /// - QSV = 1, - - /// - /// NVIDIA NVENC. - /// - NVENC = 2, - - /// - /// Video4Linux2 V4L2. - /// - V4L2M2M = 3, - - /// - /// Video Acceleration API (VAAPI). - /// - VAAPI = 4, - - /// - /// Video ToolBox. - /// - VideoToolBox = 5, - - /// - /// Rockchip Media Process Platform (RKMPP). - /// - RKMPP = 6 - } -} diff --git a/MediaBrowser.Model/Session/TranscodeReason.cs b/MediaBrowser.Model/Session/TranscodeReason.cs index bbdf4536b7..39c5ac8fa4 100644 --- a/MediaBrowser.Model/Session/TranscodeReason.cs +++ b/MediaBrowser.Model/Session/TranscodeReason.cs @@ -18,6 +18,7 @@ namespace MediaBrowser.Model.Session // Video Constraints VideoProfileNotSupported = 1 << 6, VideoRangeTypeNotSupported = 1 << 24, + VideoCodecTagNotSupported = 1 << 25, VideoLevelNotSupported = 1 << 7, VideoResolutionNotSupported = 1 << 8, VideoBitDepthNotSupported = 1 << 9, diff --git a/MediaBrowser.Model/Session/TranscodingInfo.cs b/MediaBrowser.Model/Session/TranscodingInfo.cs index 000cbd4c54..ae25267aca 100644 --- a/MediaBrowser.Model/Session/TranscodingInfo.cs +++ b/MediaBrowser.Model/Session/TranscodingInfo.cs @@ -1,34 +1,76 @@ #nullable disable -#pragma warning disable CS1591 -namespace MediaBrowser.Model.Session +using MediaBrowser.Model.Entities; + +namespace MediaBrowser.Model.Session; + +/// +/// Class holding information on a runnning transcode. +/// +public class TranscodingInfo { - public class TranscodingInfo - { - public string AudioCodec { get; set; } + /// + /// Gets or sets the thread count used for encoding. + /// + public string AudioCodec { get; set; } - public string VideoCodec { get; set; } + /// + /// Gets or sets the thread count used for encoding. + /// + public string VideoCodec { get; set; } - public string Container { get; set; } + /// + /// Gets or sets the thread count used for encoding. + /// + public string Container { get; set; } - public bool IsVideoDirect { get; set; } + /// + /// Gets or sets a value indicating whether the video is passed through. + /// + public bool IsVideoDirect { get; set; } - public bool IsAudioDirect { get; set; } + /// + /// Gets or sets a value indicating whether the audio is passed through. + /// + public bool IsAudioDirect { get; set; } - public int? Bitrate { get; set; } + /// + /// Gets or sets the bitrate. + /// + public int? Bitrate { get; set; } - public float? Framerate { get; set; } + /// + /// Gets or sets the framerate. + /// + public float? Framerate { get; set; } - public double? CompletionPercentage { get; set; } + /// + /// Gets or sets the completion percentage. + /// + public double? CompletionPercentage { get; set; } - public int? Width { get; set; } + /// + /// Gets or sets the video width. + /// + public int? Width { get; set; } - public int? Height { get; set; } + /// + /// Gets or sets the video height. + /// + public int? Height { get; set; } - public int? AudioChannels { get; set; } + /// + /// Gets or sets the audio channels. + /// + public int? AudioChannels { get; set; } - public HardwareEncodingType? HardwareAccelerationType { get; set; } + /// + /// Gets or sets the hardware acceleration type. + /// + public HardwareAccelerationType? HardwareAccelerationType { get; set; } - public TranscodeReason TranscodeReasons { get; set; } - } + /// + /// Gets or sets the transcode reasons. + /// + public TranscodeReason TranscodeReasons { get; set; } } diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs index df9d15ec20..32ab7716f7 100644 --- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs +++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs @@ -54,7 +54,14 @@ namespace MediaBrowser.Providers.BoxSets if (mergeMetadataSettings) { - targetItem.LinkedChildren = sourceItem.LinkedChildren; + if (replaceData || targetItem.LinkedChildren.Length == 0) + { + targetItem.LinkedChildren = sourceItem.LinkedChildren; + } + else + { + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray(); + } } } diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 1bb7ffccec..36a7c2fabe 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -68,16 +68,22 @@ namespace MediaBrowser.Providers.Manager /// Removes all existing images from the provided item. /// /// The to remove images from. + /// Whether removing images outside metadata folder is allowed. /// true if changes were made to the item; otherwise false. - public bool RemoveImages(BaseItem item) + public bool RemoveImages(BaseItem item, bool canDeleteLocal = false) { var singular = new List(); + var itemMetadataPath = item.GetInternalMetadataPath(); for (var i = 0; i < _singularImages.Length; i++) { var currentImage = item.GetImageInfo(_singularImages[i], 0); if (currentImage is not null) { - singular.Add(currentImage); + var imageInMetadataFolder = currentImage.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase); + if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled()) + { + singular.Add(currentImage); + } } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 8af4ed2a88..7203bf1158 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -675,6 +675,7 @@ namespace MediaBrowser.Providers.Manager }; temp.Item.Path = item.Path; temp.Item.Id = item.Id; + temp.Item.ParentIndexNumber = item.ParentIndexNumber; temp.Item.PreferredMetadataCountryCode = item.PreferredMetadataCountryCode; temp.Item.PreferredMetadataLanguage = item.PreferredMetadataLanguage; @@ -728,7 +729,7 @@ namespace MediaBrowser.Providers.Manager refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; } - MergeData(localItem, temp, Array.Empty(), false, true); + MergeData(localItem, temp, [], false, true); refreshResult.UpdateType |= ItemUpdateType.MetadataImport; break; @@ -768,7 +769,7 @@ namespace MediaBrowser.Providers.Manager if (!options.RemoveOldMetadata) { // Add existing metadata to provider result if it does not exist there - MergeData(metadata, temp, Array.Empty(), false, false); + MergeData(metadata, temp, [], false, false); } if (isLocalLocked) @@ -837,7 +838,7 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; - MergeData(result, temp, Array.Empty(), replaceData, false); + MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); refreshResult.UpdateType |= ItemUpdateType.MetadataDownload; diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 60d89a51b7..81a9af68be 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -62,7 +62,7 @@ namespace MediaBrowser.Providers.Manager private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); private readonly PriorityQueue<(Guid ItemId, MetadataRefreshOptions RefreshOptions), RefreshPriority> _refreshQueue = new(); private readonly IMemoryCache _memoryCache; - + private readonly IMediaSegmentManager _mediaSegmentManager; private readonly AsyncKeyedLocker _imageSaveLock = new(o => { o.PoolSize = 20; @@ -92,6 +92,7 @@ namespace MediaBrowser.Providers.Manager /// The BaseItem manager. /// The lyric manager. /// The memory cache. + /// The media segment manager. public ProviderManager( IHttpClientFactory httpClientFactory, ISubtitleManager subtitleManager, @@ -103,7 +104,8 @@ namespace MediaBrowser.Providers.Manager ILibraryManager libraryManager, IBaseItemManager baseItemManager, ILyricManager lyricManager, - IMemoryCache memoryCache) + IMemoryCache memoryCache, + IMediaSegmentManager mediaSegmentManager) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -116,6 +118,7 @@ namespace MediaBrowser.Providers.Manager _baseItemManager = baseItemManager; _lyricManager = lyricManager; _memoryCache = memoryCache; + _mediaSegmentManager = mediaSegmentManager; } /// @@ -572,6 +575,14 @@ namespace MediaBrowser.Providers.Manager Type = MetadataPluginType.LyricFetcher })); + // Media segment providers + var mediaSegmentProviders = _mediaSegmentManager.GetSupportedProviders(dummy); + pluginList.AddRange(mediaSegmentProviders.Select(i => new MetadataPlugin + { + Name = i.Name, + Type = MetadataPluginType.MediaSegmentProvider + })); + summary.Plugins = pluginList.ToArray(); var supportedImageTypes = imageProviders.OfType() diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 7e0773b6d3..27f6d120f9 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -6,7 +6,6 @@ using System.Threading; using System.Threading.Tasks; using ATL; using Jellyfin.Data.Enums; -using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -17,6 +16,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Extensions; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; @@ -28,6 +28,7 @@ namespace MediaBrowser.Providers.MediaInfo public class AudioFileProber { private const char InternalValueSeparator = '\u001F'; + private readonly IMediaEncoder _mediaEncoder; private readonly IItemRepository _itemRepo; private readonly ILibraryManager _libraryManager; @@ -63,6 +64,8 @@ namespace MediaBrowser.Providers.MediaInfo _lyricResolver = lyricResolver; _lyricManager = lyricManager; ATL.Settings.DisplayValueSeparator = InternalValueSeparator; + ATL.Settings.UseFileNameWhenNoTitle = false; + ATL.Settings.ID3v2_separatev2v3Values = false; } /// @@ -158,14 +161,16 @@ namespace MediaBrowser.Providers.MediaInfo /// Whether to extract embedded lyrics to lrc file. private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, bool tryExtractEmbeddedLyrics) { + var libraryOptions = _libraryManager.GetLibraryOptions(audio); Track track = new Track(audio.Path); - // ATL will fall back to filename as title when it does not understand the metadata - if (track.MetadataFormats.All(mf => mf.Equals(ATL.Factory.UNKNOWN_FORMAT))) + if (track.MetadataFormats + .All(mf => string.Equals(mf.ShortName, "ID3v1", StringComparison.OrdinalIgnoreCase))) { - track.Title = mediaInfo.Name; + _logger.LogWarning("File {File} only has ID3v1 tags, some fields may be truncated", audio.Path); } + track.Title = string.IsNullOrEmpty(track.Title) ? mediaInfo.Name : track.Title; track.Album = string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album; track.Year ??= mediaInfo.ProductionYear; track.TrackNumber ??= mediaInfo.IndexNumber; @@ -175,6 +180,12 @@ namespace MediaBrowser.Providers.MediaInfo { var people = new List(); var albumArtists = string.IsNullOrEmpty(track.AlbumArtist) ? mediaInfo.AlbumArtists : track.AlbumArtist.Split(InternalValueSeparator); + + if (libraryOptions.UseCustomTagDelimiters) + { + albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); + } + foreach (var albumArtist in albumArtists) { if (!string.IsNullOrEmpty(albumArtist)) @@ -187,7 +198,26 @@ namespace MediaBrowser.Providers.MediaInfo } } - var performers = string.IsNullOrEmpty(track.Artist) ? mediaInfo.Artists : track.Artist.Split(InternalValueSeparator); + string[]? performers = null; + if (libraryOptions.PreferNonstandardArtistsTag) + { + track.AdditionalFields.TryGetValue("ARTISTS", out var artistsTagString); + if (artistsTagString is not null) + { + performers = artistsTagString.Split(InternalValueSeparator); + } + } + + if (performers is null || performers.Length == 0) + { + performers = string.IsNullOrEmpty(track.Artist) ? mediaInfo.Artists : track.Artist.Split(InternalValueSeparator); + } + + if (libraryOptions.UseCustomTagDelimiters) + { + performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); + } + foreach (var performer in performers) { if (!string.IsNullOrEmpty(performer)) @@ -285,7 +315,13 @@ namespace MediaBrowser.Providers.MediaInfo if (!audio.LockedFields.Contains(MetadataField.Genres)) { var genres = string.IsNullOrEmpty(track.Genre) ? mediaInfo.Genres : track.Genre.Split(InternalValueSeparator).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0 + + if (libraryOptions.UseCustomTagDelimiters) + { + genres = genres.SelectMany(g => SplitWithCustomDelimiter(g, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); + } + + audio.Genres = options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 ? genres : audio.Genres; } @@ -379,5 +415,31 @@ namespace MediaBrowser.Providers.MediaInfo currentStreams.Add(externalLyricFiles[0]); } } + + private List SplitWithCustomDelimiter(string val, char[] tagDelimiters, string[] whitelist) + { + var items = new List(); + var temp = val; + foreach (var whitelistItem in whitelist) + { + if (string.IsNullOrWhiteSpace(whitelistItem)) + { + continue; + } + + var originalTemp = temp; + temp = temp.Replace(whitelistItem, string.Empty, StringComparison.OrdinalIgnoreCase); + + if (!string.Equals(temp, originalTemp, StringComparison.OrdinalIgnoreCase)) + { + items.Add(whitelistItem); + } + } + + var items2 = temp.Split(tagDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).DistinctNames(); + items.AddRange(items2); + + return items; + } } } diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs index d8855ec935..9b4793ee6c 100644 --- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs +++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.TV; @@ -12,8 +10,19 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.TV { + /// + /// Service to manage episode metadata. + /// public class EpisodeMetadataService : MetadataService { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public EpisodeMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger logger, @@ -94,6 +103,11 @@ namespace MediaBrowser.Providers.TV { targetItem.IndexNumberEnd = sourceItem.IndexNumberEnd; } + + if (replaceData || !targetItem.ParentIndexNumber.HasValue) + { + targetItem.ParentIndexNumber = sourceItem.ParentIndexNumber; + } } } } diff --git a/MediaBrowser.Providers/TV/SeasonMetadataService.cs b/MediaBrowser.Providers/TV/SeasonMetadataService.cs index 54dcee41ec..8b690193ee 100644 --- a/MediaBrowser.Providers/TV/SeasonMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeasonMetadataService.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -15,8 +13,19 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.TV { + /// + /// Service to manage season metadata. + /// public class SeasonMetadataService : MetadataService { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public SeasonMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger logger, diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 80c56351c1..f4aede463e 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -20,10 +18,22 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.TV { + /// + /// Service to manage series metadata. + /// public class SeriesMetadataService : MetadataService { private readonly ILocalizationManager _localizationManager; + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public SeriesMetadataService( IServerConfigurationManager serverConfigurationManager, ILogger logger, @@ -36,6 +46,7 @@ namespace MediaBrowser.Providers.TV _localizationManager = localizationManager; } + /// public override async Task RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) { if (item is Series series) diff --git a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs index 90c2ff8ddf..31c0eeb31e 100644 --- a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs +++ b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs @@ -98,7 +98,8 @@ public class TrickplayImagesTask : IScheduledTask try { - await _trickplayManager.RefreshTrickplayDataAsync(video, false, cancellationToken).ConfigureAwait(false); + var libraryOptions = _libraryManager.GetLibraryOptions(video); + await _trickplayManager.RefreshTrickplayDataAsync(video, false, libraryOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs new file mode 100644 index 0000000000..c0b8a8c75c --- /dev/null +++ b/MediaBrowser.Providers/Trickplay/TrickplayMoveImagesTask.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Trickplay; + +/// +/// Class TrickplayMoveImagesTask. +/// +public class TrickplayMoveImagesTask : IScheduledTask +{ + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; + private readonly ITrickplayManager _trickplayManager; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The library manager. + /// The localization manager. + /// The trickplay manager. + public TrickplayMoveImagesTask( + ILogger logger, + ILibraryManager libraryManager, + ILocalizationManager localization, + ITrickplayManager trickplayManager) + { + _libraryManager = libraryManager; + _logger = logger; + _localization = localization; + _trickplayManager = trickplayManager; + } + + /// + public string Name => _localization.GetLocalizedString("TaskMoveTrickplayImages"); + + /// + public string Description => _localization.GetLocalizedString("TaskMoveTrickplayImagesDescription"); + + /// + public string Key => "MoveTrickplayImages"; + + /// + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// + public IEnumerable GetDefaultTriggers() => []; + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + const int Limit = 100; + int itemCount = 0, offset = 0, previousCount; + + // This count may not be accurate, but just get something to show progress on the dashboard. + var totalVideoCount = _libraryManager.GetCount(new InternalItemsQuery + { + MediaTypes = [MediaType.Video], + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + IsFolder = false, + Recursive = true + }); + + var trickplayQuery = new InternalItemsQuery + { + MediaTypes = [MediaType.Video], + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + IsFolder = false + }; + + do + { + var trickplayInfos = await _trickplayManager.GetTrickplayItemsAsync(Limit, offset).ConfigureAwait(false); + previousCount = trickplayInfos.Count; + offset += Limit; + + trickplayQuery.ItemIds = trickplayInfos.Select(i => i.ItemId).Distinct().ToArray(); + var items = _libraryManager.GetItemList(trickplayQuery); + foreach (var trickplayInfo in trickplayInfos) + { + cancellationToken.ThrowIfCancellationRequested(); + + var video = items.OfType public static partial class StringExtensions { - private static readonly Lazy _transliterator = new(() => Transliterator.GetInstance( - "Any-Latin; Latin-Ascii; Lower; NFD; [:Nonspacing Mark:] Remove; [:Punctuation:] Remove;")); + private static readonly Lazy _transliteratorId = new(() => + Environment.GetEnvironmentVariable("JELLYFIN_TRANSLITERATOR_ID") + ?? "Any-Latin; Latin-Ascii; Lower; NFD; [:Nonspacing Mark:] Remove; [:Punctuation:] Remove;"); + + private static readonly Lazy _transliterator = new(() => + { + try + { + return Transliterator.GetInstance(_transliteratorId.Value); + } + catch (ArgumentException) + { + return null; + } + }); // Matches non-conforming unicode chars // https://mnaoumov.wordpress.com/2014/06/14/stripping-invalid-characters-from-utf-16-strings/ @@ -108,7 +121,7 @@ namespace Jellyfin.Extensions /// The transliterated string. public static string Transliterated(this string text) { - return _transliterator.Value.Transliterate(text); + return (_transliterator.Value is null) ? text : _transliterator.Value.Transliterate(text); } } } diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index ff00c89997..0c660637fd 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -130,7 +130,7 @@ namespace Jellyfin.LiveTv.IO const int MaxBitrate = 25000000; videoArgs = string.Format( CultureInfo.InvariantCulture, - "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", + "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -profile:v high -level 41", GetOutputSizeParam(), MaxBitrate); } @@ -157,7 +157,7 @@ namespace Jellyfin.LiveTv.IO flags.Add("+genpts"); } - var inputModifier = "-async 1 -vsync -1"; + var inputModifier = "-async 1"; if (flags.Count > 0) { diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index fef84dd000..e1f87a7bd4 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -331,6 +331,8 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun SupportsTranscoding = true, IsInfiniteStream = true, IgnoreDts = true, + UseMostCompatibleTranscodingProfile = true, // All HDHR tuners require this + FallbackMaxStreamingBitrate = info.FallbackMaxStreamingBitrate, // IgnoreIndex = true, // ReadAtNativeFramerate = true }; diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs index 365f0188df..be81171a03 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs @@ -94,7 +94,7 @@ namespace Jellyfin.LiveTv.TunerHosts var mediaSource = sources[0]; - if (mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping) + if (tunerHost.AllowStreamSharing && mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping) { var extension = Path.GetExtension(new UriBuilder(mediaSource.Path).Path); @@ -200,7 +200,9 @@ namespace Jellyfin.LiveTv.TunerHosts SupportsDirectPlay = supportsDirectPlay, SupportsDirectStream = supportsDirectStream, - RequiredHttpHeaders = httpHeaders + RequiredHttpHeaders = httpHeaders, + UseMostCompatibleTranscodingProfile = !info.AllowFmp4TranscodingContainer, + FallbackMaxStreamingBitrate = info.FallbackMaxStreamingBitrate }; mediaSource.InferTotalBitrate(); diff --git a/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs b/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs index 9a023d7ed9..1846ba26bf 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs @@ -128,7 +128,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator return false; } - internal static bool IsExtractionAllowedForFile(ReadOnlySpan filePath, string[] allowedExtensions) + internal static bool IsExtractionAllowedForFile(ReadOnlySpan filePath, IReadOnlyList allowedExtensions) { var extension = Path.GetExtension(filePath); if (extension.IsEmpty) @@ -138,7 +138,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator // Remove the leading dot var extensionWithoutDot = extension[1..]; - for (var i = 0; i < allowedExtensions.Length; i++) + for (var i = 0; i < allowedExtensions.Count; i++) { var allowedExtension = allowedExtensions[i].AsSpan().TrimStart('.'); if (extensionWithoutDot.Equals(allowedExtension, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index cf6a2cc553..b285b836bc 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -97,10 +97,15 @@ public class NetworkManager : INetworkManager, IDisposable _networkEventLock = new object(); _remoteAddressFilter = new List(); + _ = bool.TryParse(startupConfig[DetectNetworkChangeKey], out var detectNetworkChange); + UpdateSettings(_configurationManager.GetNetworkConfiguration()); - NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; - NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; + if (detectNetworkChange) + { + NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; + NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; + } _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated; } diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index f3ada59dbc..6171f12e47 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -1,4 +1,7 @@ using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.MediaInfo; +using Moq; using Xunit; namespace Jellyfin.Controller.Tests.Entities; @@ -14,4 +17,30 @@ public class BaseItemTests [InlineData("1test 2", "0000000001test 0000000002")] public void BaseItem_ModifySortChunks_Valid(string input, string expected) => Assert.Equal(expected, BaseItem.ModifySortChunks(input)); + + [Theory] + [InlineData("/Movies/Ted/Ted.mp4", "/Movies/Ted/Ted - Unrated Edition.mp4", "Ted", "Unrated Edition")] + [InlineData("/Movies/Deadpool 2 (2018)/Deadpool 2 (2018).mkv", "/Movies/Deadpool 2 (2018)/Deadpool 2 (2018) - Super Duper Cut.mkv", "Deadpool 2 (2018)", "Super Duper Cut")] + public void GetMediaSourceName_Valid(string primaryPath, string altPath, string name, string altName) + { + var mediaSourceManager = new Mock(); + mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny())) + .Returns((string x) => MediaProtocol.File); + BaseItem.MediaSourceManager = mediaSourceManager.Object; + + var video = new Video() + { + Path = primaryPath + }; + + var videoAlt = new Video() + { + Path = altPath, + }; + + video.LocalAlternateVersions = [videoAlt.Path]; + + Assert.Equal(name, video.GetMediaSourceName(video)); + Assert.Equal(altName, video.GetMediaSourceName(videoAlt)); + } } diff --git a/tests/Jellyfin.Extensions.Tests/FormattingStreamWriterTests.cs b/tests/Jellyfin.Extensions.Tests/FormattingStreamWriterTests.cs new file mode 100644 index 0000000000..06e3c27213 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/FormattingStreamWriterTests.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using Xunit; + +namespace Jellyfin.Extensions.Tests; + +public static class FormattingStreamWriterTests +{ + [Fact] + public static void Shuffle_Valid_Correct() + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE", false); + using (var ms = new MemoryStream()) + using (var txt = new FormattingStreamWriter(ms, CultureInfo.InvariantCulture)) + { + txt.Write("{0}", 3.14159); + txt.Close(); + Assert.Equal("3.14159", Encoding.UTF8.GetString(ms.ToArray())); + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs index 61105b42b2..9fc0158235 100644 --- a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs +++ b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonCommaDelimitedArrayTests.cs @@ -41,7 +41,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { "a", "b", "c" } + Value = ["a", "b", "c"] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a,b,c"" }", _jsonOptions); @@ -53,7 +53,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { "a", "b", "c" } + Value = ["a", "b", "c"] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a, b, c"" }", _jsonOptions); @@ -65,7 +65,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + Value = [GeneralCommandType.MoveUp, GeneralCommandType.MoveDown] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,MoveDown"" }", _jsonOptions); @@ -77,7 +77,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + Value = [GeneralCommandType.MoveUp, GeneralCommandType.MoveDown] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,,MoveDown"" }", _jsonOptions); @@ -89,7 +89,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + Value = [GeneralCommandType.MoveUp, GeneralCommandType.MoveDown] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,TotallyNotAVallidCommand,MoveDown"" }", _jsonOptions); @@ -101,7 +101,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + Value = [GeneralCommandType.MoveUp, GeneralCommandType.MoveDown] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp, MoveDown"" }", _jsonOptions); @@ -113,7 +113,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { "a", "b", "c" } + Value = ["a", "b", "c"] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""a"",""b"",""c""] }", _jsonOptions); @@ -125,7 +125,7 @@ namespace Jellyfin.Extensions.Tests.Json.Converters { var desiredValue = new GenericBodyArrayModel { - Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown } + Value = [GeneralCommandType.MoveUp, GeneralCommandType.MoveDown] }; var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", _jsonOptions); diff --git a/tests/Jellyfin.Model.Tests/Dlna/ContainerHelperTests.cs b/tests/Jellyfin.Model.Tests/Dlna/ContainerHelperTests.cs new file mode 100644 index 0000000000..1ad4bed567 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/ContainerHelperTests.cs @@ -0,0 +1,83 @@ +using System; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Extensions; +using Xunit; + +namespace Jellyfin.Model.Tests.Dlna; + +public class ContainerHelperTests +{ + private readonly ContainerProfile _emptyContainerProfile = new ContainerProfile(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("mp4")] + public void ContainsContainer_EmptyContainerProfile_ReturnsTrue(string? containers) + { + Assert.True(_emptyContainerProfile.ContainsContainer(containers)); + } + + [Theory] + [InlineData("mp3,mpeg", "mp3")] + [InlineData("mp3,mpeg,avi", "mp3,avi")] + [InlineData("-mp3,mpeg", "avi")] + [InlineData("-mp3,mpeg,avi", "mp4,jpg")] + public void ContainsContainer_InList_ReturnsTrue(string container, string? extension) + { + Assert.True(ContainerHelper.ContainsContainer(container, extension)); + } + + [Theory] + [InlineData("mp3,mpeg", "avi")] + [InlineData("mp3,mpeg,avi", "mp4,jpg")] + [InlineData("mp3,mpeg", null)] + [InlineData("mp3,mpeg", "")] + [InlineData("-mp3,mpeg", "mp3")] + [InlineData("-mp3,mpeg,avi", "mpeg,avi")] + [InlineData(",mp3,", ",avi,")] // Empty values should be discarded + [InlineData("-,mp3,", ",mp3,")] // Empty values should be discarded + public void ContainsContainer_NotInList_ReturnsFalse(string container, string? extension) + { + Assert.False(ContainerHelper.ContainsContainer(container, extension)); + + if (extension is not null) + { + Assert.False(ContainerHelper.ContainsContainer(container, extension.AsSpan())); + } + } + + [Theory] + [InlineData("mp3,mpeg", "mp3")] + [InlineData("mp3,mpeg,avi", "mp3,avi")] + [InlineData("-mp3,mpeg", "avi")] + [InlineData("-mp3,mpeg,avi", "mp4,jpg")] + public void ContainsContainer_InList_ReturnsTrue_SpanVersion(string container, string? extension) + { + Assert.True(ContainerHelper.ContainsContainer(container, extension.AsSpan())); + } + + [Theory] + [InlineData(new string[] { "mp3", "mpeg" }, false, "mpeg")] + [InlineData(new string[] { "mp3", "mpeg", "avi" }, false, "avi")] + [InlineData(new string[] { "mp3", "", "avi" }, false, "mp3")] + [InlineData(new string[] { "mp3", "mpeg" }, true, "avi")] + [InlineData(new string[] { "mp3", "mpeg", "avi" }, true, "mkv")] + [InlineData(new string[] { "mp3", "", "avi" }, true, "")] + public void ContainsContainer_ThreeArgs_InList_ReturnsTrue(string[] containers, bool isNegativeList, string inputContainer) + { + Assert.True(ContainerHelper.ContainsContainer(containers, isNegativeList, inputContainer)); + } + + [Theory] + [InlineData(new string[] { "mp3", "mpeg" }, false, "avi")] + [InlineData(new string[] { "mp3", "mpeg", "avi" }, false, "mkv")] + [InlineData(new string[] { "mp3", "", "avi" }, false, "")] + [InlineData(new string[] { "mp3", "mpeg" }, true, "mpeg")] + [InlineData(new string[] { "mp3", "mpeg", "avi" }, true, "mp3")] + [InlineData(new string[] { "mp3", "", "avi" }, true, "avi")] + public void ContainsContainer_ThreeArgs_InList_ReturnsFalse(string[] containers, bool isNegativeList, string inputContainer) + { + Assert.False(ContainerHelper.ContainsContainer(containers, isNegativeList, inputContainer)); + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/ContainerProfileTests.cs b/tests/Jellyfin.Model.Tests/Dlna/ContainerProfileTests.cs deleted file mode 100644 index cca056c280..0000000000 --- a/tests/Jellyfin.Model.Tests/Dlna/ContainerProfileTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MediaBrowser.Model.Dlna; -using Xunit; - -namespace Jellyfin.Model.Tests.Dlna -{ - public class ContainerProfileTests - { - private readonly ContainerProfile _emptyContainerProfile = new ContainerProfile(); - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("mp4")] - public void ContainsContainer_EmptyContainerProfile_True(string? containers) - { - Assert.True(_emptyContainerProfile.ContainsContainer(containers)); - } - } -} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 31ddd427cc..bd2143f252 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -22,37 +22,58 @@ namespace Jellyfin.Model.Tests [Theory] // Chrome [InlineData("Chrome", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] - [InlineData("Chrome", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] - [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioIsExternal, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mkv-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay)] + [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] + [InlineData("Chrome", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] + [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] // #6450 + [InlineData("Chrome", "mp4-h264-hi10p-aac-5000k", PlayMethod.DirectPlay)] + [InlineData("Chrome", "mkv-h264-hi10p-aac-5000k-brokenfps", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "Remux", "HLS.mp4")] + [InlineData("Chrome", "mp4-dvh1.05-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Chrome", "mkv-dvhe.05-eac3-28000k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Chrome", "mkv-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Chrome", "mp4-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] // Firefox [InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] - [InlineData("Firefox", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] - [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioIsExternal, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mkv-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] // #6450 + [InlineData("Firefox", "mp4-h264-hi10p-aac-5000k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoProfileNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mkv-h264-hi10p-aac-5000k-brokenfps", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoProfileNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mp4-dvh1.05-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mkv-dvhe.05-eac3-28000k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mkv-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mp4-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] // Safari [InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("SafariNext", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("SafariNext", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("SafariNext", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("SafariNext", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("SafariNext", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450 - [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 - [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mkv-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecTagNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecTagNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecTagNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mp4-h264-hi10p-aac-5000k", PlayMethod.Transcode, TranscodeReason.VideoProfileNotSupported, "Remux", "HLS.mp4")] + [InlineData("SafariNext", "mkv-h264-hi10p-aac-5000k-brokenfps", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoProfileNotSupported, "Remux", "HLS.mp4")] + [InlineData("SafariNext", "mp4-dvh1.05-eac3-15200k", PlayMethod.DirectPlay)] + [InlineData("SafariNext", "mkv-dvhe.05-eac3-28000k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] + [InlineData("SafariNext", "mkv-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] + [InlineData("SafariNext", "mp4-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecTagNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // AndroidPixel [InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 @@ -62,21 +83,21 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay - [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc [InlineData("RokuSSPlus", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // JellyfinMediaPlayer [InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -86,18 +107,18 @@ namespace Jellyfin.Model.Tests [InlineData("JellyfinMediaPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("JellyfinMediaPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("JellyfinMediaPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - // Chrome-NoHLS + // Non-HLS Progressive transcoding [InlineData("Chrome-NoHLS", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 - [InlineData("Chrome-NoHLS", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // #6450 + [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "http")] // #6450 + [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioIsExternal, "Remux", "http")] // #6450 + [InlineData("Chrome-NoHLS", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // #6450 [InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "http")] [InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")] - [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 + [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "DirectStream", "http")] // webm requested, aac not supported + [InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // #6450 + [InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux", "http")] // #6450 // TranscodeMedia [InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")] @@ -147,7 +168,7 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 // Tizen 3 Stereo [InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] @@ -155,7 +176,7 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen3-stereo", "mp4-h264-dts-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] - [InlineData("Tizen3-stereo", "mp4-hevc-truehd-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Tizen3-stereo", "mp4-hevc-truehd-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Tizen3-stereo", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] @@ -163,13 +184,18 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen4-4K-5.1", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("Tizen4-4K-5.1", "mp4-h264-dts-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Tizen4-4K-5.1", "mp4-h264-dts-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Tizen4-4K-5.1", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] - [InlineData("Tizen4-4K-5.1", "mp4-hevc-truehd-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Tizen4-4K-5.1", "mp4-hevc-truehd-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] + // WebOS 23 + [InlineData("WebOS-23", "mkv-dvhe.08-eac3-15200k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported, "Remux")] + [InlineData("WebOS-23", "mp4-dvh1.05-eac3-15200k", PlayMethod.DirectPlay)] + [InlineData("WebOS-23", "mp4-dvhe.08-eac3-15200k", PlayMethod.DirectPlay)] + [InlineData("WebOS-23", "mkv-dvhe.05-eac3-28000k", PlayMethod.Transcode, TranscodeReason.VideoRangeTypeNotSupported, "Remux")] public async Task BuildVideoItemSimple(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = default, string transcodeMode = "DirectStream", string transcodeProtocol = "") { var options = await GetMediaOptions(deviceName, mediaSource); @@ -179,24 +205,24 @@ namespace Jellyfin.Model.Tests [Theory] // Chrome [InlineData("Chrome", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] - [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay)] + [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] + [InlineData("Chrome", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux", "HLS.mp4")] // #6450 // Firefox [InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] - [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] - [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported, "Remux")] // #6450 - [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "HLS.mp4")] + [InlineData("Firefox", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "HLS.mp4")] // #6450 [InlineData("Firefox", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Safari [InlineData("SafariNext", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 @@ -204,9 +230,10 @@ namespace Jellyfin.Model.Tests [InlineData("SafariNext", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("SafariNext", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("SafariNext", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("SafariNext", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450 - [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 - [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecTagNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecTagNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecTagNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450 + // AndroidPixel [InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 [InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 @@ -215,19 +242,19 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 should be DirectPlay - [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 should be DirectPlay + [InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 - [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 + [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc + [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // JellyfinMediaPlayer [InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -245,7 +272,7 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9 // Tizen 3 Stereo [InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] @@ -253,7 +280,7 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen3-stereo", "mp4-h264-dts-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] - [InlineData("Tizen3-stereo", "mp4-hevc-truehd-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Tizen3-stereo", "mp4-hevc-truehd-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Tizen3-stereo", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen3-stereo", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] @@ -261,10 +288,10 @@ namespace Jellyfin.Model.Tests [InlineData("Tizen4-4K-5.1", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] - [InlineData("Tizen4-4K-5.1", "mp4-h264-dts-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Tizen4-4K-5.1", "mp4-h264-dts-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Tizen4-4K-5.1", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)] - [InlineData("Tizen4-4K-5.1", "mp4-hevc-truehd-srt-15200k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] + [InlineData("Tizen4-4K-5.1", "mp4-hevc-truehd-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)] [InlineData("Tizen4-4K-5.1", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] @@ -281,34 +308,37 @@ namespace Jellyfin.Model.Tests [Theory] // Chrome - [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 - [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] + [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] + [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioIsExternal, "DirectStream", "HLS.mp4")] // #6450 + [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] // Firefox - [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] + [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux", "HLS.mp4")] + [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "HLS.mp4")] // Yatse - [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // no streams - [InlineData("Chrome", "no-streams", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] // #6450 + [InlineData("Chrome", "no-streams", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "HLS.mp4")] // #6450 // AndroidTV [InlineData("AndroidTVExoPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] [InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // Tizen 3 Stereo - [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Tizen3-stereo", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen3-stereo", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // Tizen 4 4K 5.1 - [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] - [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen4-4K-5.1", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + [InlineData("Tizen4-4K-5.1", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] + // TranscodeMedia + [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")] + [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-mp3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.ts")] public async Task BuildVideoItemWithDirectPlayExplicitStreams(string deviceName, string mediaSource, PlayMethod? playMethod, TranscodeReason why = default, string transcodeMode = "DirectStream", string transcodeProtocol = "") { var options = await GetMediaOptions(deviceName, mediaSource); @@ -328,7 +358,7 @@ namespace Jellyfin.Model.Tests { if (string.IsNullOrEmpty(transcodeProtocol)) { - transcodeProtocol = playMethod == PlayMethod.DirectStream ? "http" : "HLS.ts"; + transcodeProtocol = "HLS.ts"; } var builder = GetStreamBuilder(); @@ -359,25 +389,30 @@ namespace Jellyfin.Model.Tests if (playMethod == PlayMethod.DirectPlay) { // Check expected container - var containers = ContainerProfile.SplitValue(mediaSource.Container); + var containers = mediaSource.Container.Split(','); + Assert.Contains(uri.Extension, containers); // TODO: Test transcode too - // Assert.Contains(uri.Extension, containers); // Check expected video codec (1) - Assert.Contains(targetVideoStream?.Codec, streamInfo.TargetVideoCodec); - Assert.Single(streamInfo.TargetVideoCodec); + if (targetVideoStream?.Codec is not null) + { + Assert.Contains(targetVideoStream?.Codec, streamInfo.TargetVideoCodec); + Assert.Single(streamInfo.TargetVideoCodec); + } - // Check expected audio codecs (1) - Assert.Contains(targetAudioStream?.Codec, streamInfo.TargetAudioCodec); - Assert.Single(streamInfo.TargetAudioCodec); - // Assert.Single(val.AudioCodecs); + if (targetAudioStream?.Codec is not null) + { + // Check expected audio codecs (1) + Assert.Contains(targetAudioStream?.Codec, streamInfo.TargetAudioCodec); + Assert.Single(streamInfo.TargetAudioCodec); + } if (transcodeMode.Equals("DirectStream", StringComparison.Ordinal)) { Assert.Equal(streamInfo.Container, uri.Extension); } } - else if (playMethod == PlayMethod.DirectStream || playMethod == PlayMethod.Transcode) + else if (playMethod == PlayMethod.Transcode) { Assert.NotNull(streamInfo.Container); Assert.NotEmpty(streamInfo.VideoCodecs); @@ -409,7 +444,7 @@ namespace Jellyfin.Model.Tests // Full transcode if (transcodeMode.Equals("Transcode", StringComparison.Ordinal)) { - if ((streamInfo.TranscodeReasons & (StreamBuilder.ContainerReasons | TranscodeReason.DirectPlayError)) == 0) + if ((streamInfo.TranscodeReasons & (StreamBuilder.ContainerReasons | TranscodeReason.DirectPlayError | TranscodeReason.VideoRangeTypeNotSupported)) == 0) { Assert.All( videoStreams, @@ -547,6 +582,7 @@ namespace Jellyfin.Model.Tests Profile = dp, AllowAudioStreamCopy = true, AllowVideoStreamCopy = true, + EnableDirectStream = false // This is disabled in server }; } diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json index 81bb97ac82..e2f75b569b 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json @@ -16,324 +16,200 @@ "DirectPlayProfiles": [ { "Container": "webm", - "AudioCodec": "vorbis,opus", - "VideoCodec": "vp8,vp9,av1", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "vp8,vp9,av1", + "AudioCodec": "vorbis,opus" }, { "Container": "mp4,m4v", - "AudioCodec": "aac,mp3,opus,flac,alac,vorbis", - "VideoCodec": "h264,vp8,vp9,av1", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "h264,hevc,vp9,av1", + "AudioCodec": "aac,mp3,mp2,opus,flac,vorbis" }, { "Container": "mov", - "AudioCodec": "aac,mp3,opus,flac,alac,vorbis", - "VideoCodec": "h264", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "h264", + "AudioCodec": "aac,mp3,mp2,opus,flac,vorbis" }, { "Container": "opus", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "webm", "AudioCodec": "opus", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" + }, + { + "Container": "ts", + "AudioCodec": "mp3", + "Type": "Audio" }, { "Container": "mp3", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "m4a", "AudioCodec": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "m4b", "AudioCodec": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "flac", - "Type": "Audio", - "$type": "DirectPlayProfile" - }, - { - "Container": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" - }, - { - "Container": "m4a", - "AudioCodec": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" - }, - { - "Container": "m4b", - "AudioCodec": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "webma", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "webm", "AudioCodec": "webma", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "wav", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "ogg", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" + }, + { + "Container": "hls", + "Type": "Video", + "VideoCodec": "av1,hevc,h264,vp9", + "AudioCodec": "aac,mp2,opus,flac" + }, + { + "Container": "hls", + "Type": "Video", + "VideoCodec": "h264", + "AudioCodec": "aac,mp3,mp2" } ], "TranscodingProfiles": [ { - "Container": "ts", + "Container": "mp4", "Type": "Audio", "AudioCodec": "aac", - "Protocol": "hls", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 2, - "SegmentLength": 0, + "Protocol": "hls", + "MaxAudioChannels": "2", + "MinSegments": "2", "BreakOnNonKeyFrames": true, - "$type": "TranscodingProfile" + "EnableAudioVbrEncoding": true }, { "Container": "aac", "Type": "Audio", "AudioCodec": "aac", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "2" }, { "Container": "mp3", "Type": "Audio", "AudioCodec": "mp3", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "2" }, { "Container": "opus", "Type": "Audio", "AudioCodec": "opus", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "2" }, { "Container": "wav", "Type": "Audio", "AudioCodec": "wav", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "2" }, { "Container": "opus", "Type": "Audio", "AudioCodec": "opus", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "2" }, { "Container": "mp3", "Type": "Audio", "AudioCodec": "mp3", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "2" }, { "Container": "aac", "Type": "Audio", "AudioCodec": "aac", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "2" }, { "Container": "wav", "Type": "Audio", "AudioCodec": "wav", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "ts", - "Type": "Video", - "VideoCodec": "h264", - "AudioCodec": "aac,mp3", - "Protocol": "hls", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 2, - "SegmentLength": 0, - "BreakOnNonKeyFrames": true, - "$type": "TranscodingProfile" - }, - { - "Container": "webm", - "Type": "Video", - "VideoCodec": "vp8,vp9,av1,vpx", - "AudioCodec": "vorbis,opus", "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "MaxAudioChannels": "2" }, { "Container": "mp4", "Type": "Video", + "AudioCodec": "aac,mp2,opus,flac", + "VideoCodec": "av1,hevc,h264,vp9", + "Context": "Streaming", + "Protocol": "hls", + "MaxAudioChannels": "2", + "MinSegments": "2", + "BreakOnNonKeyFrames": true + }, + { + "Container": "ts", + "Type": "Video", + "AudioCodec": "aac,mp3,mp2", "VideoCodec": "h264", - "AudioCodec": "aac,mp3,opus,flac,alac,vorbis", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Static", - "EnableSubtitlesInManifest": false, - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Context": "Streaming", + "Protocol": "hls", + "MaxAudioChannels": "2", + "MinSegments": "2", + "BreakOnNonKeyFrames": true } ], + "ContainerProfiles": [], "CodecProfiles": [ { "Type": "VideoAudio", + "Codec": "aac", "Conditions": [ { "Condition": "Equals", "Property": "IsSecondaryAudio", "Value": "false", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false } - ], - "Codec": "aac", - "$type": "CodecProfile" + ] }, { "Type": "VideoAudio", @@ -342,107 +218,144 @@ "Condition": "Equals", "Property": "IsSecondaryAudio", "Value": "false", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false } - ], - "$type": "CodecProfile" + ] }, { "Type": "Video", + "Codec": "h264", "Conditions": [ { "Condition": "NotEquals", "Property": "IsAnamorphic", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false }, { "Condition": "EqualsAny", "Property": "VideoProfile", "Value": "high|main|baseline|constrained baseline|high 10", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR", + "IsRequired": false }, { "Condition": "LessThanEqual", "Property": "VideoLevel", "Value": "52", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false }, { "Condition": "NotEquals", "Property": "IsInterlaced", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false } - ], - "Codec": "h264", - "$type": "CodecProfile" + ] }, { "Type": "Video", + "Codec": "hevc", "Conditions": [ { "Condition": "NotEquals", "Property": "IsAnamorphic", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false }, { "Condition": "EqualsAny", "Property": "VideoProfile", - "Value": "main", - "IsRequired": false, - "$type": "ProfileCondition" + "Value": "main|main 10", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false }, { "Condition": "LessThanEqual", "Property": "VideoLevel", - "Value": "120", - "IsRequired": false, - "$type": "ProfileCondition" + "Value": "183", + "IsRequired": false }, { "Condition": "NotEquals", "Property": "IsInterlaced", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false } - ], - "Codec": "hevc", - "$type": "CodecProfile" - } - ], - "ResponseProfiles": [ + ] + }, { - "Container": "m4v", "Type": "Video", - "MimeType": "video/mp4", - "$type": "ResponseProfile" + "Codec": "vp9", + "Conditions": [ + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Codec": "av1", + "Conditions": [ + { + "Condition": "NotEquals", + "Property": "IsAnamorphic", + "Value": "true", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoProfile", + "Value": "main", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false + }, + { + "Condition": "LessThanEqual", + "Property": "VideoLevel", + "Value": "19", + "IsRequired": false + } + ] } ], "SubtitleProfiles": [ { "Format": "vtt", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" }, { "Format": "ass", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" }, { "Format": "ssa", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" } ], - "$type": "DeviceProfile" + "ResponseProfiles": [ + { + "Type": "Video", + "Container": "m4v", + "MimeType": "video/mp4" + } + ] } diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json index 9874793d37..21ae7e5cb3 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json @@ -15,426 +15,357 @@ "IgnoreTranscodeByteRangeRequests": false, "DirectPlayProfiles": [ { - "Container": "webm", "AudioCodec": "vorbis,opus", - "VideoCodec": "vp8,vp9,av1", + "Container": "webm", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "vp8,vp9,av1" }, { + "AudioCodec": "aac,mp3,mp2,opus,flac,vorbis", "Container": "mp4,m4v", - "AudioCodec": "aac,mp3,opus,flac,alac,vorbis", - "VideoCodec": "h264,vp8,vp9,av1", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "h264,vp9,av1" }, { "Container": "opus", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { - "Container": "webm", "AudioCodec": "opus", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Container": "webm", + "Type": "Audio" + }, + { + "AudioCodec": "mp3", + "Container": "ts", + "Type": "Audio" }, { "Container": "mp3", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { + "AudioCodec": "aac", "Container": "m4a", - "AudioCodec": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { - "Container": "m4b", "AudioCodec": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Container": "m4b", + "Type": "Audio" }, { "Container": "flac", - "Type": "Audio", - "$type": "DirectPlayProfile" - }, - { - "Container": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" - }, - { - "Container": "m4a", - "AudioCodec": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" - }, - { - "Container": "m4b", - "AudioCodec": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "webma", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { - "Container": "webm", "AudioCodec": "webma", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Container": "webm", + "Type": "Audio" }, { "Container": "wav", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "ogg", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" + }, + { + "AudioCodec": "aac,mp2,opus,flac", + "Container": "hls", + "Type": "Video", + "VideoCodec": "av1,h264,vp9" + }, + { + "AudioCodec": "aac,mp3,mp2", + "Container": "hls", + "Type": "Video", + "VideoCodec": "h264" } ], "TranscodingProfiles": [ { - "Container": "ts", - "Type": "Audio", "AudioCodec": "aac", - "Protocol": "hls", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 2, - "SegmentLength": 0, "BreakOnNonKeyFrames": true, - "$type": "TranscodingProfile" - }, - { - "Container": "aac", - "Type": "Audio", - "AudioCodec": "aac", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "mp3", - "Type": "Audio", - "AudioCodec": "mp3", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "opus", - "Type": "Audio", - "AudioCodec": "opus", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "wav", - "Type": "Audio", - "AudioCodec": "wav", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "opus", - "Type": "Audio", - "AudioCodec": "opus", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "mp3", - "Type": "Audio", - "AudioCodec": "mp3", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "aac", - "Type": "Audio", - "AudioCodec": "aac", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "wav", - "Type": "Audio", - "AudioCodec": "wav", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { - "Container": "ts", - "Type": "Video", - "VideoCodec": "h264", - "AudioCodec": "aac,mp3", - "Protocol": "hls", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 2, - "SegmentLength": 0, - "BreakOnNonKeyFrames": true, - "$type": "TranscodingProfile" - }, - { - "Container": "webm", - "Type": "Video", - "VideoCodec": "vp8,vp9,av1,vpx", - "AudioCodec": "vorbis,opus", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" - }, - { "Container": "mp4", - "Type": "Video", - "VideoCodec": "h264", - "AudioCodec": "aac,mp3,opus,flac,alac,vorbis", + "Context": "Streaming", + "EnableAudioVbrEncoding": true, + "MaxAudioChannels": "2", + "MinSegments": "2", + "Protocol": "hls", + "Type": "Audio" + }, + { + "AudioCodec": "aac", + "Container": "aac", + "Context": "Streaming", + "MaxAudioChannels": "2", "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, + "Type": "Audio" + }, + { + "AudioCodec": "mp3", + "Container": "mp3", + "Context": "Streaming", + "MaxAudioChannels": "2", + "Protocol": "http", + "Type": "Audio" + }, + { + "AudioCodec": "opus", + "Container": "opus", + "Context": "Streaming", + "MaxAudioChannels": "2", + "Protocol": "http", + "Type": "Audio" + }, + { + "AudioCodec": "wav", + "Container": "wav", + "Context": "Streaming", + "MaxAudioChannels": "2", + "Protocol": "http", + "Type": "Audio" + }, + { + "AudioCodec": "opus", + "Container": "opus", "Context": "Static", - "EnableSubtitlesInManifest": false, - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "MaxAudioChannels": "2", + "Protocol": "http", + "Type": "Audio" + }, + { + "AudioCodec": "mp3", + "Container": "mp3", + "Context": "Static", + "MaxAudioChannels": "2", + "Protocol": "http", + "Type": "Audio" + }, + { + "AudioCodec": "aac", + "Container": "aac", + "Context": "Static", + "MaxAudioChannels": "2", + "Protocol": "http", + "Type": "Audio" + }, + { + "AudioCodec": "wav", + "Container": "wav", + "Context": "Static", + "MaxAudioChannels": "2", + "Protocol": "http", + "Type": "Audio" + }, + { + "AudioCodec": "aac,mp2,opus,flac", + "BreakOnNonKeyFrames": true, + "Container": "mp4", + "Context": "Streaming", + "MaxAudioChannels": "2", + "MinSegments": "2", + "Protocol": "hls", + "Type": "Video", + "VideoCodec": "av1,h264,vp9" + }, + { + "AudioCodec": "aac,mp3,mp2", + "BreakOnNonKeyFrames": true, + "Container": "ts", + "Context": "Streaming", + "MaxAudioChannels": "2", + "MinSegments": "2", + "Protocol": "hls", + "Type": "Video", + "VideoCodec": "h264" } ], "CodecProfiles": [ { - "Type": "VideoAudio", - "Conditions": [ - { - "Condition": "Equals", - "Property": "IsSecondaryAudio", - "Value": "false", - "IsRequired": false, - "$type": "ProfileCondition" - } - ], "Codec": "aac", - "$type": "CodecProfile" - }, - { - "Type": "VideoAudio", "Conditions": [ { "Condition": "Equals", + "IsRequired": false, "Property": "IsSecondaryAudio", - "Value": "false", - "IsRequired": false, - "$type": "ProfileCondition" + "Value": "false" } ], - "$type": "CodecProfile" + "Type": "VideoAudio" }, { - "Type": "Video", "Conditions": [ - { - "Condition": "NotEquals", - "Property": "IsAnamorphic", - "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" - }, - { - "Condition": "EqualsAny", - "Property": "VideoProfile", - "Value": "high|main|baseline|constrained baseline", - "IsRequired": false, - "$type": "ProfileCondition" - }, { "Condition": "LessThanEqual", - "Property": "VideoLevel", - "Value": "52", "IsRequired": false, - "$type": "ProfileCondition" - }, - { - "Condition": "NotEquals", - "Property": "IsInterlaced", - "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "Property": "AudioChannels", + "Value": "2" } ], + "Type": "Audio" + }, + { + "Conditions": [ + { + "Condition": "LessThanEqual", + "IsRequired": false, + "Property": "AudioChannels", + "Value": "2" + }, + { + "Condition": "Equals", + "IsRequired": false, + "Property": "IsSecondaryAudio", + "Value": "false" + } + ], + "Type": "VideoAudio" + }, + { "Codec": "h264", - "$type": "CodecProfile" - }, - { - "Type": "Video", "Conditions": [ { "Condition": "NotEquals", - "Property": "IsAnamorphic", - "Value": "true", "IsRequired": false, - "$type": "ProfileCondition" + "Property": "IsAnamorphic", + "Value": "true" }, { "Condition": "EqualsAny", - "Property": "VideoProfile", - "Value": "main", "IsRequired": false, - "$type": "ProfileCondition" + "Property": "VideoProfile", + "Value": "high|main|baseline|constrained baseline" + }, + { + "Condition": "EqualsAny", + "IsRequired": false, + "Property": "VideoRangeType", + "Value": "SDR" }, { "Condition": "LessThanEqual", - "Property": "VideoLevel", - "Value": "120", "IsRequired": false, - "$type": "ProfileCondition" + "Property": "VideoLevel", + "Value": "52" }, { "Condition": "NotEquals", - "Property": "IsInterlaced", - "Value": "true", "IsRequired": false, - "$type": "ProfileCondition" + "Property": "IsInterlaced", + "Value": "true" } ], + "Type": "Video" + }, + { "Codec": "hevc", - "$type": "CodecProfile" + "Conditions": [ + { + "Condition": "NotEquals", + "IsRequired": false, + "Property": "IsAnamorphic", + "Value": "true" + }, + { + "Condition": "EqualsAny", + "IsRequired": false, + "Property": "VideoProfile", + "Value": "main" + }, + { + "Condition": "EqualsAny", + "IsRequired": false, + "Property": "VideoRangeType", + "Value": "SDR" + }, + { + "Condition": "LessThanEqual", + "IsRequired": false, + "Property": "VideoLevel", + "Value": "120" + }, + { + "Condition": "NotEquals", + "IsRequired": false, + "Property": "IsInterlaced", + "Value": "true" + } + ], + "Type": "Video" + }, + { + "Codec": "vp9", + "Conditions": [ + { + "Condition": "EqualsAny", + "IsRequired": false, + "Property": "VideoRangeType", + "Value": "SDR" + } + ], + "Type": "Video" + }, + { + "Codec": "av1", + "Conditions": [ + { + "Condition": "NotEquals", + "IsRequired": false, + "Property": "IsAnamorphic", + "Value": "true" + }, + { + "Condition": "EqualsAny", + "IsRequired": false, + "Property": "VideoProfile", + "Value": "main" + }, + { + "Condition": "EqualsAny", + "IsRequired": false, + "Property": "VideoRangeType", + "Value": "SDR" + }, + { + "Condition": "LessThanEqual", + "IsRequired": false, + "Property": "VideoLevel", + "Value": "19" + } + ], + "Type": "Video" } ], "ResponseProfiles": [ { "Container": "m4v", - "Type": "Video", "MimeType": "video/mp4", - "$type": "ResponseProfile" + "Type": "Video" } ], "SubtitleProfiles": [ { "Format": "vtt", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" }, { "Format": "ass", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" }, { "Format": "ssa", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" } ], "$type": "DeviceProfile" diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json index 3b5a0c2549..f61d0e36bd 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json @@ -16,211 +16,160 @@ "DirectPlayProfiles": [ { "Container": "webm", - "AudioCodec": "vorbis", - "VideoCodec": "vp8,vp9", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "vp8", + "AudioCodec": "vorbis,opus" }, { "Container": "mp4,m4v", - "AudioCodec": "aac,mp3,ac3,eac3,flac,alac,vorbis", - "VideoCodec": "h264,vp8,vp9", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "h264,hevc,vp9", + "AudioCodec": "aac,ac3,eac3,opus,flac,alac" }, { "Container": "mov", - "AudioCodec": "aac,mp3,ac3,eac3,flac,alac,vorbis", - "VideoCodec": "h264", "Type": "Video", - "$type": "DirectPlayProfile" + "VideoCodec": "h264", + "AudioCodec": "aac,ac3,eac3,opus,flac,alac" + }, + { + "Container": "ts", + "AudioCodec": "mp3", + "Type": "Audio" }, { "Container": "mp3", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "m4a", "AudioCodec": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "m4b", "AudioCodec": "aac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { - "Container": "flac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Container": "mp4", + "AudioCodec": "flac", + "Type": "Audio" }, { "Container": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "m4a", "AudioCodec": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "m4b", "AudioCodec": "alac", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "webma", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "webm", "AudioCodec": "webma", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" }, { "Container": "wav", - "Type": "Audio", - "$type": "DirectPlayProfile" + "Type": "Audio" + }, + { + "Container": "mp4", + "AudioCodec": "opus", + "Type": "Audio" + }, + { + "Container": "hls", + "Type": "Video", + "VideoCodec": "hevc,h264,vp9", + "AudioCodec": "aac,ac3,eac3,opus,flac,alac" + }, + { + "Container": "hls", + "Type": "Video", + "VideoCodec": "h264", + "AudioCodec": "aac,mp3,ac3,eac3" } ], "TranscodingProfiles": [ { - "Container": "aac", + "Container": "mp4", "Type": "Audio", "AudioCodec": "aac", + "Context": "Streaming", "Protocol": "hls", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, - "Context": "Streaming", - "EnableSubtitlesInManifest": false, "MaxAudioChannels": "6", - "MinSegments": 2, - "SegmentLength": 0, + "MinSegments": "2", "BreakOnNonKeyFrames": true, - "$type": "TranscodingProfile" + "EnableAudioVbrEncoding": true }, { "Container": "aac", "Type": "Audio", "AudioCodec": "aac", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "6" }, { "Container": "mp3", "Type": "Audio", "AudioCodec": "mp3", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "6" }, { "Container": "wav", "Type": "Audio", "AudioCodec": "wav", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Streaming", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "6" }, { "Container": "mp3", "Type": "Audio", "AudioCodec": "mp3", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "6" }, { "Container": "aac", "Type": "Audio", "AudioCodec": "aac", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "6" }, { "Container": "wav", "Type": "Audio", "AudioCodec": "wav", - "Protocol": "http", - "EstimateContentLength": false, - "EnableMpegtsM2TsMode": false, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": false, "Context": "Static", - "EnableSubtitlesInManifest": false, - "MaxAudioChannels": "6", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": false, - "$type": "TranscodingProfile" + "Protocol": "http", + "MaxAudioChannels": "6" }, { "Container": "mp4", "Type": "Video", - "AudioCodec": "aac,ac3,eac3,flac,alac", - "VideoCodec": "hevc,h264", + "AudioCodec": "aac,ac3,eac3,opus,flac,alac", + "VideoCodec": "hevc,h264,vp9", "Context": "Streaming", "Protocol": "hls", "MaxAudioChannels": "2", @@ -237,121 +186,170 @@ "MaxAudioChannels": "2", "MinSegments": "2", "BreakOnNonKeyFrames": true - }, - { - "Container": "webm", - "Type": "Video", - "AudioCodec": "vorbis", - "VideoCodec": "vp8,vpx", - "Context": "Streaming", - "Protocol": "http", - "MaxAudioChannels": "2" - }, - { - "Container": "mp4", - "Type": "Video", - "AudioCodec": "aac,mp3,ac3,eac3,flac,alac,vorbis", - "VideoCodec": "h264", - "Context": "Static", - "Protocol": "http" } ], + "ContainerProfiles": [], "CodecProfiles": [ { "Type": "Video", + "Container": "hls", + "SubContainer": "mp4", + "Codec": "h264", + "Conditions": [ + { + "Condition": "EqualsAny", + "Property": "VideoProfile", + "Value": "high|main|baseline|constrained baseline|high 10", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Codec": "h264", "Conditions": [ { "Condition": "NotEquals", "Property": "IsAnamorphic", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false }, { "Condition": "EqualsAny", "Property": "VideoProfile", "Value": "high|main|baseline|constrained baseline", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR", + "IsRequired": false }, { "Condition": "LessThanEqual", "Property": "VideoLevel", "Value": "52", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false }, { "Condition": "NotEquals", "Property": "IsInterlaced", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false } - ], - "Codec": "h264", - "$type": "CodecProfile" + ] }, { "Type": "Video", + "Codec": "hevc", "Conditions": [ { "Condition": "NotEquals", "Property": "IsAnamorphic", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false }, { "Condition": "EqualsAny", "Property": "VideoProfile", "Value": "main|main 10", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG|DOVI|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR", + "IsRequired": false }, { "Condition": "LessThanEqual", "Property": "VideoLevel", "Value": "183", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false }, { "Condition": "NotEquals", "Property": "IsInterlaced", "Value": "true", - "IsRequired": false, - "$type": "ProfileCondition" + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoCodecTag", + "Value": "hvc1|dvh1", + "IsRequired": true + }, + { + "Condition": "LessThanEqual", + "Property": "VideoFramerate", + "Value": "60", + "IsRequired": true } - ], - "Codec": "hevc", - "$type": "CodecProfile" - } - ], - "ResponseProfiles": [ + ] + }, { - "Container": "m4v", "Type": "Video", - "MimeType": "video/mp4", - "$type": "ResponseProfile" + "Codec": "vp9", + "Conditions": [ + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Codec": "av1", + "Conditions": [ + { + "Condition": "NotEquals", + "Property": "IsAnamorphic", + "Value": "true", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoProfile", + "Value": "main", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false + }, + { + "Condition": "LessThanEqual", + "Property": "VideoLevel", + "Value": "15", + "IsRequired": false + } + ] } ], "SubtitleProfiles": [ { "Format": "vtt", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" }, { "Format": "ass", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" }, { "Format": "ssa", - "Method": "External", - "$type": "SubtitleProfile" + "Method": "External" } ], - "$type": "DeviceProfile" + "ResponseProfiles": [ + { + "Type": "Video", + "Container": "m4v", + "MimeType": "video/mp4" + } + ] } diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json new file mode 100644 index 0000000000..094b0723b1 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json @@ -0,0 +1,355 @@ +{ + "MaxStreamingBitrate": 120000000, + "MaxStaticBitrate": 100000000, + "MusicStreamingTranscodingBitrate": 384000, + "DirectPlayProfiles": [ + { + "Container": "webm", + "Type": "Video", + "VideoCodec": "vp8,vp9,av1", + "AudioCodec": "vorbis,opus" + }, + { + "Container": "mp4,m4v", + "Type": "Video", + "VideoCodec": "h264,hevc,mpeg2video,vc1,vp9,av1", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "mkv", + "Type": "Video", + "VideoCodec": "h264,hevc,mpeg2video,vc1,vp9,av1", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "m2ts", + "Type": "Video", + "VideoCodec": "h264,vc1,mpeg2video", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "wmv", + "Type": "Video", + "VideoCodec": "", + "AudioCodec": "" + }, + { + "Container": "ts,mpegts", + "Type": "Video", + "VideoCodec": "h264,hevc,vc1,mpeg2video", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "asf", + "Type": "Video", + "VideoCodec": "", + "AudioCodec": "" + }, + { + "Container": "avi", + "Type": "Video", + "VideoCodec": "", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "mpg", + "Type": "Video", + "VideoCodec": "", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "mpeg", + "Type": "Video", + "VideoCodec": "", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "mov", + "Type": "Video", + "VideoCodec": "h264", + "AudioCodec": "aac,ac3,eac3,mp2,pcm_s16le,pcm_s24le,opus,flac,vorbis" + }, + { + "Container": "opus", + "Type": "Audio" + }, + { + "Container": "webm", + "AudioCodec": "opus", + "Type": "Audio" + }, + { + "Container": "ts", + "AudioCodec": "mp3", + "Type": "Audio" + }, + { + "Container": "mp3", + "Type": "Audio" + }, + { + "Container": "aac", + "Type": "Audio" + }, + { + "Container": "m4a", + "AudioCodec": "aac", + "Type": "Audio" + }, + { + "Container": "m4b", + "AudioCodec": "aac", + "Type": "Audio" + }, + { + "Container": "mp4", + "AudioCodec": "flac", + "Type": "Audio" + }, + { + "Container": "webma", + "Type": "Audio" + }, + { + "Container": "webm", + "AudioCodec": "webma", + "Type": "Audio" + }, + { + "Container": "wav", + "Type": "Audio" + }, + { + "Container": "hls", + "Type": "Video", + "VideoCodec": "h264,hevc", + "AudioCodec": "aac,ac3,eac3,mp2" + } + ], + "TranscodingProfiles": [ + { + "Container": "ts", + "Type": "Audio", + "AudioCodec": "aac", + "Context": "Streaming", + "Protocol": "hls", + "MaxAudioChannels": "6", + "MinSegments": "1", + "BreakOnNonKeyFrames": false, + "EnableAudioVbrEncoding": true + }, + { + "Container": "aac", + "Type": "Audio", + "AudioCodec": "aac", + "Context": "Streaming", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "mp3", + "Type": "Audio", + "AudioCodec": "mp3", + "Context": "Streaming", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "opus", + "Type": "Audio", + "AudioCodec": "opus", + "Context": "Streaming", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "wav", + "Type": "Audio", + "AudioCodec": "wav", + "Context": "Streaming", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "opus", + "Type": "Audio", + "AudioCodec": "opus", + "Context": "Static", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "mp3", + "Type": "Audio", + "AudioCodec": "mp3", + "Context": "Static", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "aac", + "Type": "Audio", + "AudioCodec": "aac", + "Context": "Static", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "wav", + "Type": "Audio", + "AudioCodec": "wav", + "Context": "Static", + "Protocol": "http", + "MaxAudioChannels": "6" + }, + { + "Container": "ts", + "Type": "Video", + "AudioCodec": "aac,ac3,eac3,mp2", + "VideoCodec": "h264,hevc", + "Context": "Streaming", + "Protocol": "hls", + "MaxAudioChannels": "6", + "MinSegments": "1", + "BreakOnNonKeyFrames": false + } + ], + "ContainerProfiles": [], + "CodecProfiles": [ + { + "Type": "VideoAudio", + "Codec": "flac", + "Conditions": [ + { + "Condition": "LessThanEqual", + "Property": "AudioChannels", + "Value": "2", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Condition": "NotEquals", + "Property": "IsAnamorphic", + "Value": "true", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoProfile", + "Value": "high|main|baseline|constrained baseline", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR", + "IsRequired": false + }, + { + "Condition": "LessThanEqual", + "Property": "VideoLevel", + "Value": "52", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Container": "-mp4,ts", + "Codec": "hevc", + "Conditions": [ + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Codec": "hevc", + "Conditions": [ + { + "Condition": "NotEquals", + "Property": "IsAnamorphic", + "Value": "true", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoProfile", + "Value": "main|main 10", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG|DOVI|DOVIWithHDR10|DOVIWithHLG|DOVIWithSDR", + "IsRequired": false + }, + { + "Condition": "LessThanEqual", + "Property": "VideoLevel", + "Value": "183", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Codec": "vp9", + "Conditions": [ + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false + } + ] + }, + { + "Type": "Video", + "Codec": "av1", + "Conditions": [ + { + "Condition": "NotEquals", + "Property": "IsAnamorphic", + "Value": "true", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoProfile", + "Value": "main", + "IsRequired": false + }, + { + "Condition": "EqualsAny", + "Property": "VideoRangeType", + "Value": "SDR|HDR10|HLG", + "IsRequired": false + }, + { + "Condition": "LessThanEqual", + "Property": "VideoLevel", + "Value": "15", + "IsRequired": false + } + ] + } + ], + "SubtitleProfiles": [], + "ResponseProfiles": [ + { + "Type": "Video", + "Container": "m4v", + "MimeType": "video/mp4" + } + ] +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-dvhe.05-eac3-28000k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-dvhe.05-eac3-28000k.json new file mode 100644 index 0000000000..2fdd332769 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-dvhe.05-eac3-28000k.json @@ -0,0 +1,95 @@ +{ + "Id": "e313fd4bfdfcab326b1fea833cffd779", + "Path": "/Media/MyVideo-dovi-p5.mkv", + "Type": "Default", + "Container": "mkv", + "Size": 199246498, + "Name": "MyVideo-dovi-p5", + "IsRemote": false, + "ETag": "3c932ee1cd94e3fecebcc3fac15053e9", + "RunTimeTicks": 562000000, + "SupportsTranscoding": true, + "SupportsDirectStream": false, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "hevc", + "CodecTag": "dvhe", + "Language": "und", + "DvVersionMajor": 1, + "DvVersionMinor": 0, + "DvProfile": 5, + "DvLevel": 9, + "RpuPresentFlag": 1, + "ElPresentFlag": 0, + "BlPresentFlag": 1, + "DvBlSignalCompatibilityId": 0, + "TimeBase": "1/60000", + "VideoRange": "HDR", + "VideoRangeType": "DOVI", + "VideoDoViTitle": "Dolby Vision Profile 5", + "AudioSpatialFormat": "None", + "DisplayTitle": "4K HEVC Dolby Vision Profile 5", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 27713921, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 2160, + "Width": 3840, + "AverageFrameRate": 60, + "RealFrameRate": 60, + "ReferenceFrameRate": 60, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 153, + "IsAnamorphic": false + }, + { + "Codec": "eac3", + "CodecTag": "ec-3", + "Language": "und", + "TimeBase": "1/48000", + "Title": "sound handler", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "DolbyAtmos", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "sound handler - Dolby Digital Plus + Dolby Atmos - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "Dolby Digital Plus + Dolby Atmos", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Bitrate": 28362490, + "RequiredHttpHeaders": {}, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": -1, + "HasSegments": false +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-dvhe.08-eac3-15200k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-dvhe.08-eac3-15200k.json new file mode 100644 index 0000000000..c4197fe314 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-dvhe.08-eac3-15200k.json @@ -0,0 +1,97 @@ +{ + "Protocol": "File", + "Id": "ac2a9824755fbeffd891b8ff2634901a", + "Path": "/Media/MyVideo-dovi-p8.mkv", + "Type": "Default", + "Container": "mkv", + "Size": 344509829, + "Name": "MyVideo-dovi-p8", + "ETag": "8ac40cacc99e4748bc9218045b38d184", + "RunTimeTicks": 1781120000, + "SupportsTranscoding": true, + "SupportsDirectStream": false, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "hevc", + "CodecTag": "hev1", + "Language": "und", + "ColorSpace": "bt2020nc", + "ColorTransfer": "smpte2084", + "ColorPrimaries": "bt2020", + "DvVersionMajor": 1, + "DvVersionMinor": 0, + "DvProfile": 8, + "DvLevel": 5, + "RpuPresentFlag": 1, + "ElPresentFlag": 0, + "BlPresentFlag": 1, + "DvBlSignalCompatibilityId": 1, + "TimeBase": "1/60000", + "VideoRange": "HDR", + "VideoRangeType": "DOVIWithHDR10", + "VideoDoViTitle": "Dolby Vision Profile 8.1 (HDR10)", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p HEVC Dolby Vision Profile 8.1 (HDR10)", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 15091058, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1920, + "AverageFrameRate": 59.94006, + "RealFrameRate": 59.94006, + "ReferenceFrameRate": 59.94006, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 153, + "IsAnamorphic": false + }, + { + "Codec": "eac3", + "CodecTag": "ec-3", + "Language": "und", + "TimeBase": "1/48000", + "Title": "Bento4 Sound Handler", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "DolbyAtmos", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Bento4 Sound Handler - Dolby Digital Plus + Dolby Atmos - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "Dolby Digital Plus + Dolby Atmos", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 15473851, + "DefaultAudioStreamIndex": 1, + "HasSegments": false +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-h264-ac3-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-h264-ac3-srt-2600k.json new file mode 100644 index 0000000000..4f6d5bf000 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-h264-ac3-srt-2600k.json @@ -0,0 +1,71 @@ +{ + "Id": "a766d122b58e45d9492d17af77748bf5", + "Path": "/Media/MyVideo-720p.mkv", + "Container": "mkv", + "Size": 835317696, + "Name": "MyVideo-720p", + "ETag": "579a34c6d5dfb21d81539a51220b6a23", + "RunTimeTicks": 25801230336, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "h264", + "CodecTag": "avc1", + "Language": "eng", + "TimeBase": "1/11988", + "VideoRange": "SDR", + "DisplayTitle": "720p H264 SDR", + "NalLengthSize": "0", + "BitRate": 2032876, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "Height": 720, + "Width": 1280, + "AverageFrameRate": 23.976, + "RealFrameRate": 23.976, + "Profile": "High", + "Type": 1, + "AspectRatio": "16:9", + "PixelFormat": "yuv420p", + "Level": 41 + }, + { + "Codec": "ac3", + "CodecTag": "ac-3", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - Dolby Digital - 5.1 - Default", + "ChannelLayout": "5.1", + "BitRate": 384000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "Index": 1, + "Score": 202 + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 2, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true + } + ], + "Bitrate": 2590008, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 2 +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-h264-hi10p-aac-5000k-brokenfps.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-h264-hi10p-aac-5000k-brokenfps.json new file mode 100644 index 0000000000..b2dda6c5d4 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mkv-h264-hi10p-aac-5000k-brokenfps.json @@ -0,0 +1,82 @@ +{ + "Protocol": "File", + "Id": "a6e78000340509437325708e41b9e3bb", + "Path": "/Media/hi10p.mkv", + "Type": "Default", + "Container": "mkv", + "Size": 58211635, + "Name": "MyVideo-hi10p-brokenfps", + "IsRemote": false, + "ETag": "60c03cb8a315fb6538439d3bb7e6944b", + "RunTimeTicks": 920115000, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "h264", + "TimeBase": "1/1000", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "720p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 5075104, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 720, + "Width": 1280, + "AverageFrameRate": 1000, + "RealFrameRate": 23.976025, + "ReferenceFrameRate": 23.976025, + "Profile": "High 10", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 51, + "IsAnamorphic": false + }, + { + "Codec": "aac", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "AAC - Stereo - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 192000, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 5061248, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": -1, + "HasSegments": false +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-dvh1.05-eac3-15200k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-dvh1.05-eac3-15200k.json new file mode 100644 index 0000000000..96e3caffc3 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-dvh1.05-eac3-15200k.json @@ -0,0 +1,94 @@ +{ + "Id": "a5365160a83cb0c518cc1c9ead31dbc7", + "Path": "/Media/MyVideo-dovi-p5.mp4", + "Type": "Default", + "Container": "mp4", + "Size": 345485021, + "Name": "MyVideo-dovi-p5", + "IsRemote": false, + "ETag": "a1aa7e722b9af5125b7387d0f58d463e", + "RunTimeTicks": 1781120000, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "hevc", + "CodecTag": "dvh1", + "Language": "und", + "DvVersionMajor": 1, + "DvVersionMinor": 0, + "DvProfile": 5, + "DvLevel": 5, + "RpuPresentFlag": 1, + "ElPresentFlag": 0, + "BlPresentFlag": 1, + "DvBlSignalCompatibilityId": 0, + "TimeBase": "1/60000", + "VideoRange": "HDR", + "VideoRangeType": "DOVI", + "VideoDoViTitle": "Dolby Vision Profile 5", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p HEVC Dolby Vision Profile 5", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 15135631, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1920, + "AverageFrameRate": 59.94006, + "RealFrameRate": 59.94006, + "ReferenceFrameRate": 59.94006, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 153, + "IsAnamorphic": false + }, + { + "Codec": "eac3", + "CodecTag": "ec-3", + "Language": "und", + "TimeBase": "1/48000", + "Title": "Bento4 Sound Handler", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "DolbyAtmos", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Bento4 Sound Handler - Dolby Digital Plus + Dolby Atmos - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "Dolby Digital Plus + Dolby Atmos", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Bitrate": 15517652, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": -1, + "HasSegments": false +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-dvhe.08-eac3-15200k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-dvhe.08-eac3-15200k.json new file mode 100644 index 0000000000..6f77a8805e --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-dvhe.08-eac3-15200k.json @@ -0,0 +1,97 @@ +{ + "Protocol": "File", + "Id": "ac2a9824755fbeffd891b8ff2634901a", + "Path": "/Media/MyVideo-dovi-p8.mp4", + "Type": "Default", + "Container": "mp4", + "Size": 344509829, + "Name": "MyVideo-dovi-p8", + "ETag": "8ac40cacc99e4748bc9218045b38d184", + "RunTimeTicks": 1781120000, + "SupportsTranscoding": true, + "SupportsDirectStream": false, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "hevc", + "CodecTag": "hev1", + "Language": "und", + "ColorSpace": "bt2020nc", + "ColorTransfer": "smpte2084", + "ColorPrimaries": "bt2020", + "DvVersionMajor": 1, + "DvVersionMinor": 0, + "DvProfile": 8, + "DvLevel": 5, + "RpuPresentFlag": 1, + "ElPresentFlag": 0, + "BlPresentFlag": 1, + "DvBlSignalCompatibilityId": 1, + "TimeBase": "1/60000", + "VideoRange": "HDR", + "VideoRangeType": "DOVIWithHDR10", + "VideoDoViTitle": "Dolby Vision Profile 8.1 (HDR10)", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p HEVC Dolby Vision Profile 8.1 (HDR10)", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 15091058, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1920, + "AverageFrameRate": 59.94006, + "RealFrameRate": 59.94006, + "ReferenceFrameRate": 59.94006, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 153, + "IsAnamorphic": false + }, + { + "Codec": "eac3", + "CodecTag": "ec-3", + "Language": "und", + "TimeBase": "1/48000", + "Title": "Bento4 Sound Handler", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "DolbyAtmos", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Bento4 Sound Handler - Dolby Digital Plus + Dolby Atmos - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "Dolby Digital Plus + Dolby Atmos", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 15473851, + "DefaultAudioStreamIndex": 1, + "HasSegments": false +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-mp3-srt-2600k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-mp3-srt-2600k.json new file mode 100644 index 0000000000..2e05e70d69 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-ac3-aac-mp3-srt-2600k.json @@ -0,0 +1,100 @@ +{ + "Id": "a766d122b58e45d9492d17af77748bf5", + "Path": "/Media/MyVideo-720p.mp4", + "Container": "mov,mp4,m4a,3gp,3g2,mj2", + "Size": 835317696, + "Name": "MyVideo-720p", + "ETag": "579a34c6d5dfb21d81539a51220b6a23", + "RunTimeTicks": 25801230336, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "h264", + "CodecTag": "avc1", + "Language": "eng", + "TimeBase": "1/11988", + "VideoRange": "SDR", + "DisplayTitle": "720p H264 SDR", + "NalLengthSize": "0", + "BitRate": 2032876, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "Height": 720, + "Width": 1280, + "AverageFrameRate": 23.976, + "RealFrameRate": 23.976, + "Profile": "High", + "Type": 1, + "AspectRatio": "16:9", + "PixelFormat": "yuv420p", + "Level": 41 + }, + { + "Codec": "ac3", + "CodecTag": "ac-3", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - Dolby Digital - 5.1 - Default", + "ChannelLayout": "5.1", + "BitRate": 384000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "Index": 1, + "Score": 202 + }, + { + "Codec": "aac", + "CodecTag": "mp4a", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - AAC - Stereo", + "ChannelLayout": "stereo", + "BitRate": 164741, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "Profile": "LC", + "Index": 2, + "Score": 203 + }, + { + "Codec": "mp3", + "Language": "eng", + "TimeBase": "1/48000", + "DisplayTitle": "En - MP3 - Stereo", + "ChannelLayout": "stereo", + "BitRate": 164741, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "Index": 3, + "Score": 203 + }, + { + "Codec": "srt", + "Language": "eng", + "TimeBase": "1/1000000", + "localizedUndefined": "Undefined", + "localizedDefault": "Default", + "localizedForced": "Forced", + "DisplayTitle": "En - Default", + "BitRate": 92, + "IsDefault": true, + "Type": 2, + "Index": 4, + "Score": 6421, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "/Media/MyVideo-WEBDL-2160p.default.eng.srt" + } + ], + "Bitrate": 2590008, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 4 +} diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-hi10p-aac-5000k.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-hi10p-aac-5000k.json new file mode 100644 index 0000000000..1296bece5a --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-h264-hi10p-aac-5000k.json @@ -0,0 +1,86 @@ +{ + "Protocol": "File", + "Id": "a6e78000340509437325708e41b9e3bb", + "Path": "/Media/hi10p.mp4", + "Type": "Default", + "Container": "mov", + "Size": 58211635, + "Name": "MyVideo-hi10p", + "IsRemote": false, + "ETag": "8ad487e37ce9578122bbd8c42be2a392", + "RunTimeTicks": 920115000, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "h264", + "CodecTag": "avc1", + "Language": "und", + "TimeBase": "1/16000", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "720p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 4820299, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 720, + "Width": 1280, + "AverageFrameRate": 24.007952, + "RealFrameRate": 23.976025, + "ReferenceFrameRate": 24.007952, + "Profile": "High 10", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 51, + "IsAnamorphic": false + }, + { + "Codec": "aac", + "CodecTag": "mp4a", + "Language": "und", + "TimeBase": "1/48000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "AAC - Stereo - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 257358, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 5061248, + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": -1, + "HasSegments": false +} diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 183ec89848..6b13986957 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -25,8 +25,8 @@ namespace Jellyfin.Naming.Tests.Video files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result.Where(v => v.ExtraType is null)); - Assert.Single(result.Where(v => v.ExtraType is not null)); + Assert.Single(result, v => v.ExtraType is null); + Assert.Single(result, v => v.ExtraType is not null); } [Fact] @@ -44,8 +44,8 @@ namespace Jellyfin.Naming.Tests.Video files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), _namingOptions).ToList(); - Assert.Single(result.Where(v => v.ExtraType is null)); - Assert.Single(result.Where(v => v.ExtraType is not null)); + Assert.Single(result, v => v.ExtraType is null); + Assert.Single(result, v => v.ExtraType is not null); Assert.Equal(2, result[0].AlternateVersions.Count); } @@ -356,6 +356,45 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[4].Path); } + [Fact] + public void TestMultiVersion13() + { + 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) - 1080p Directors Cut.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", + }; + + var result = VideoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType().ToList(), + _namingOptions).ToList(); + + Assert.Single(result); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path); + Assert.Equal(11, result[0].AlternateVersions.Count); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", result[0].AlternateVersions[1].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[2].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Directors Cut.mkv", result[0].AlternateVersions[3].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", result[0].AlternateVersions[4].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", result[0].AlternateVersions[5].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", result[0].AlternateVersions[6].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[7].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", result[0].AlternateVersions[8].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[9].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[10].Path); + } + [Fact] public void Resolve_GivenFolderNameWithBracketsAndHyphens_GroupsBasedOnFolderName() { diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index 5dd3eb8ab9..0c7d2487cb 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -580,6 +580,7 @@ namespace Jellyfin.Providers.Tests.Manager CallBase = true }; item.Setup(m => m.IsSaveLocalMetadataEnabled()).Returns(false); + item.Setup(m => m.GetInternalMetadataPath()).Returns(string.Empty); var path = validPaths ? _testDataImagePath.Format : "invalid path {0}"; for (int i = 0; i < count; i++) diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index cced2b1e26..c227883b50 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -574,7 +574,8 @@ namespace Jellyfin.Providers.Tests.Manager libraryManager.Object, baseItemManager!, Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); return providerManager; } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs deleted file mode 100644 index c267d3dd35..0000000000 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Net; -using System.Threading.Tasks; -using Xunit; - -namespace Jellyfin.Server.Integration.Tests.Controllers; - -public class SessionControllerTests : IClassFixture -{ - private readonly JellyfinApplicationFactory _factory; - private static string? _accessToken; - - public SessionControllerTests(JellyfinApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task GetSessions_NonExistentUserId_NotFound() - { - var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - - using var response = await client.GetAsync($"Sessions?controllableByUserId={Guid.NewGuid()}"); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } -} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs index 3721d1f7ac..12d6e1934d 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs @@ -157,7 +157,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers _parser.Fetch(result, "Test Data/Sonarr-Thumb.nfo", CancellationToken.None); - Assert.Single(result.RemoteImages.Where(x => x.Type == ImageType.Primary)); + Assert.Single(result.RemoteImages, x => x.Type == ImageType.Primary); Assert.Equal("https://artworks.thetvdb.com/banners/episodes/359095/7081317.jpg", result.RemoteImages.First(x => x.Type == ImageType.Primary).Url); } diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index 20a8f6152f..b9833c2250 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -220,7 +220,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers _parser.Fetch(result, "Test Data/Fanart.nfo", CancellationToken.None); - Assert.Single(result.RemoteImages.Where(x => x.Type == ImageType.Backdrop)); + Assert.Single(result.RemoteImages, x => x.Type == ImageType.Backdrop); Assert.Equal("https://assets.fanart.tv/fanart/movies/141052/moviebackground/justice-league-5a5332c7b5e77.jpg", result.RemoteImages.First(x => x.Type == ImageType.Backdrop).Url); }