diff options
55 files changed, 782 insertions, 339 deletions
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index c2acaaccb..31ae50263 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -1,59 +1,131 @@ name: Issue Report description: File an issue report -title: "[Issue]: " labels: [bug, triage] body: - type: markdown + id: introduction attributes: value: | - Thanks for taking the time to report an issue. Before submitting a report, please do the following: - 1. Please head to our forum or chat rooms and troubleshoot with volunteers if you haven't already. Links can be found here: https://jellyfin.org/contact/ - 2. Please search the bug tracker for similar issues. If you do find one, please comment there instead of opening a new bug report. - 3. If you decide to open a new report, please provide as much detail as possible. - 4. Please **ONLY** report **ONE** issue per report. If you are experiencing multiple issues, please open multiple reports. + ### Thank you for taking the time to report an issue! + Please keep in mind that Jellyfin is a [free and open-source](https://jellyfin.org/docs/general/about) project, made up entirely and exclusively of **volunteers** who donate their free time to the project. + - type: checkboxes + id: before-posting + attributes: + label: "This issue respects the following points:" + description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment. + options: + - label: This is a **bug**, not a question or a configuration issue; Please visit our forum or chat rooms first to troubleshoot with volunteers, before creating a report. The links can be found [here](https://jellyfin.org/contact/). + required: true + - label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_. + required: true + - label: I'm using an up to date version of Jellyfin Server stable, unstable or master; We generally do not support previous older versions. If possible, please update to the latest version before opening an issue. + required: true + - label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct). + required: true + - label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one. + required: true + - type: markdown + id: preliminary-information + attributes: + value: | + ### General preliminary information + + Please keep the following in mind when creating this issue: + + 1. Fill in as much of the template as possible. When you are unsure about the relevancy of a section, do include the information requested in that section. Only leave out information in sections when you are completely sure about it not being relevant. + 2. Provide as much detail as possible. Do not assume other people to know what is going on. + 3. Keep everything readable and structured. Nobody enjoys reading poorly written reports that are difficult to understand. + 4. Keep an eye on your report as long as it is open, your involvement might be requested at a later moment. + 5. Keep the title short and descriptive. The title is not the place to write down a full description of the issue. + 6. When deciding to leave out information in a field, leave it blank and empty. Avoid writing things such as `n/a` for empty fields. - type: textarea - id: what-happened + id: bug-description attributes: - label: Please describe your bug - description: Also tell us, what did you expect to happen? + label: Description of the bug + description: Please provide a detailed description on the bug you encountered, in a readable and comprehensible way. placeholder: | - The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful. - If you are using an old release of Jellyfin, please also explain why. + After upgrading to version x.y.z of Jellyfin, the "login disclaimer" is showing incorrect text. It appears to me that it is appending the server name to the end of the login disclaimer, and showing that to a user. It might be a regression from pull request x. I have tried rebooting my host as well as my container multiple times. I tested this functionality on different clients, and it happens to all the tested clients (client x, y, z), that support the login disclaimer functionality. This makes me believe it is a server side issue. validations: required: true - type: textarea id: repro-steps attributes: - label: Reproduction Steps + label: Reproduction steps + description: Reproduction steps should be complete and self-contained. Anyone can reproduce this issue by following these steps. Furthermore, the steps should be clear and easy to follow. + placeholder: | + 1. Sign in on the Jellyfin web client, with an admin account, using a browser of your choice. + 2. Navigate to the dashboard. + 3. Select "general". + 4. Change the login disclaimer to something like "I am a cool disclaimer!" + 5. Save the settings. + 6. Sign out. + 7. Make sure you are on the sign in screen. Otherwise, navigate to the sign in screen manually. + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: What is the current _bug_ behavior? + description: Write down the incorrect behavior that currently happens after following the reproduction steps. + placeholder: | + The login disclaimer on the sign in screen has the server name appended to the text. The text shown is: "I am a cool disclaimer!jellyfinserver". + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: What is the expected _correct_ behavior? + description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps. placeholder: | - 1. In this environment... - 2. With this config... - 3. Run '...' - 4. See error... + The login disclaimer on the sign in screen should only show the configured text. The text that should be shown is: "I am a cool disclaimer!". validations: required: true - type: dropdown id: version attributes: - label: Jellyfin Version - description: What version of Jellyfin are you running? + label: Jellyfin Server version + description: What version of Jellyfin are you using? options: - - 10.9.0 - - 10.8.13 - - 10.8.12 or older (please specify) - - Weekly unstable (please specify) - - Master branch + - 10.9.7 + - Master + - Unstable + - Older* validations: required: true - type: input - id: version-other + id: version-master + attributes: + label: "Specify commit id" + description: Fill in this field in case the option 'master' is selected. Provide the commit id it was built on. + placeholder: | + 610e56baafc3011e1bfa043bdabb567bda0c2ab0 + - type: input + id: version-unstable + attributes: + label: "Specify unstable release number" + description: Fill in this field in case the option 'unstable' is selected. Provide the unstable release number. + placeholder: | + 2024050906 + - type: input + id: version-older attributes: - label: "if other:" - placeholder: Other + label: "Specify version number" + description: Fill in this field in case the option 'older' is selected. Provide the version number. + placeholder: | + x.y.z + - type: input + id: build-version + attributes: + label: "Specify the build version" + description: Please provide the build version that is shown in the dashboard. + validations: + required: true - type: textarea + id: environment-information attributes: label: Environment description: | + Accurately fill in as much environment details as possible. If a certain environment field is not shown in the template below, but you consider useful information, please include it. Examples: - **OS**: [e.g. Debian 11, Windows 10] - **Linux Kernel**: [e.g. none, 5.15, 6.1, etc.] @@ -88,21 +160,22 @@ body: validations: required: true - type: markdown + id: general-information-logs attributes: value: | - When providing logs, please keep the following things in mind. - 1. **DO NOT** use external paste services. + When providing logs, please keep the following things in mind: + 1. **DO NOT** use external paste services. If logs are too large to paste into the field, upload them as text files. 2. Please provide complete logs. - - For server logs, include everything you think is important plus *10 lines before and after* + - For server logs, ensure to capture all relevant information, encompassing both the events leading up to and following the occurrence of the issue. Typically, providing 10 *lines preceding and succeeding* the problem should be adequate. - For ffmpeg logs, please provide the entire file unmodified. - 3. Please do not run logs through any translation program. Especially beware if your browser translates pages by default. + 3. Please do not run logs through any translation program. We exclusively accept raw, untranslated logs. Particularly exercise caution if your browser automatically translates pages by default. + - Do not forget to censor out personal information such as public IP addresses. 4. Please do not include logs as screenshots, with the only exception being client logs in browsers. - type: textarea - id: logs + id: jellyfin-logs attributes: label: Jellyfin logs description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. - placeholder: For playback issues, browser/client and FFmpeg logs may be more useful. render: shell validations: required: true @@ -110,24 +183,20 @@ body: id: ffmpeg-logs attributes: label: FFmpeg logs - description: Please copy and paste recent FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log. - placeholder: This field is mandatory for debugging hardware transcoding issues. It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg. + description: Relevant FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log. This field is considered mandatory for transcoding related issues. It's also important to include the specific codec details. render: shell - type: textarea - id: browserlogs + id: browser-logs attributes: - label: Please attach any browser or client logs here - placeholder: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation. + label: Client / Browser logs + description: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation. - type: textarea id: screenshots attributes: - label: Please attach any screenshots here - placeholder: Images can be pasted directly into the textbox and will be hosted by github. - - type: checkboxes - id: terms + label: Relevant screenshots or videos + description: Attach relevant screenshots or videos related to this report. + - type: textarea + id: additional-information attributes: - label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct) - options: - - label: I agree to follow this project's Code of Conduct - required: true + label: Additional information + description: Any additional information that might be useful to this issue. diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index e9ae80b93..c6ea1d7ca 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + uses: github/codeql-action/init@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + uses: github/codeql-action/autobuild@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + uses: github/codeql-action/analyze@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 0ac955cc4..91c2be87b 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@345a7c3866622fe946263314218ad2c12c388b8b # 5.3.7 + uses: danielpalme/ReportGenerator-GitHub-Action@5808021ec4deecb0ab3da051d49b4ce65fcc20af # 5.3.8 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8f63bd6b4..edbc846d6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -185,6 +185,7 @@ - [Vedant](https://github.com/viktory36/) - [NotSaifA](https://github.com/NotSaifA) - [HonestlyWhoKnows](https://github.com/honestlywhoknows) + - [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index f2127c8a6..825301bfc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -84,8 +84,8 @@ <PackageVersion Include="TMDbLib" Version="2.2.0" /> <PackageVersion Include="UTF.Unknown" Version="2.5.1" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> - <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.1" /> + <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" /> - <PackageVersion Include="xunit" Version="2.8.1" /> + <PackageVersion Include="xunit" Version="2.9.0" /> </ItemGroup> </Project>
\ No newline at end of file diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 39524be1d..dc845b2d7 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -104,6 +104,6 @@ namespace Emby.Server.Implementations.AppBase /// Gets the folder path to the temp directory within the cache folder. /// </summary> /// <value>The temp directory.</value> - public string TempDirectory => Path.Combine(CachePath, "temp"); + public string TempDirectory => Path.Join(Path.GetTempPath(), "jellyfin"); } } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c394b25bd..5bf9c4fc2 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -664,7 +664,8 @@ namespace Emby.Server.Implementations GetExports<IMetadataService>(), GetExports<IMetadataProvider>(), GetExports<IMetadataSaver>(), - GetExports<IExternalId>()); + GetExports<IExternalId>(), + GetExports<IExternalUrlProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 81ee55d26..5094dcf0d 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -1046,9 +1046,10 @@ namespace Emby.Server.Implementations.Data foreach (var part in value.SpanSplit('|')) { var providerDelimiterIndex = part.IndexOf('='); - if (providerDelimiterIndex != -1 && providerDelimiterIndex == part.LastIndexOf('=')) + // Don't let empty values through + if (providerDelimiterIndex != -1 && part.Length != providerDelimiterIndex + 1) { - item.SetProviderId(part.Slice(0, providerDelimiterIndex).ToString(), part.Slice(providerDelimiterIndex + 1).ToString()); + item.SetProviderId(part[..providerDelimiterIndex].ToString(), part[(providerDelimiterIndex + 1)..].ToString()); } } } @@ -5693,13 +5694,17 @@ AND Type = @InternalPersonType)"); item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result; - if (item.Type == MediaStreamType.Subtitle) + if (item.Type is MediaStreamType.Audio or MediaStreamType.Subtitle) { - item.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); item.LocalizedDefault = _localization.GetLocalizedString("Default"); - item.LocalizedForced = _localization.GetLocalizedString("Forced"); item.LocalizedExternal = _localization.GetLocalizedString("External"); - item.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); + + if (item.Type is MediaStreamType.Subtitle) + { + item.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); + item.LocalizedForced = _localization.GetLocalizedString("Forced"); + item.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); + } } return item; diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 250bec9ea..28bb29df8 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -466,7 +466,7 @@ namespace Emby.Server.Implementations.IO File.Copy(file1, temp1, true); File.Copy(file2, file1, true); - File.Copy(temp1, file2, true); + File.Move(temp1, file2, true); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 0a3d740cc..82db7c46b 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -122,6 +122,7 @@ namespace Emby.Server.Implementations.Images } await ProviderManager.SaveImage(item, outputPath, mimeType, imageType, null, false, cancellationToken).ConfigureAwait(false); + File.Delete(outputPath); return ItemUpdateType.ImageUpdate; } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index ac2248264..cbded1ec6 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1,4 +1,5 @@ #pragma warning disable CS1591 +#pragma warning disable CA5394 using System; using System.Collections.Concurrent; @@ -16,6 +17,7 @@ using Emby.Server.Implementations.Library.Resolvers; using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks.Tasks; +using Emby.Server.Implementations.Sorting; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -1710,13 +1712,19 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder) { - var isFirst = true; - IOrderedEnumerable<BaseItem>? orderedItems = null; foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null)) { - if (isFirst) + if (orderBy is RandomComparer) + { + var randomItems = items.ToArray(); + Random.Shared.Shuffle(randomItems); + items = randomItems; + // Items are no longer ordered at this point, so set orderedItems back to null + orderedItems = null; + } + else if (orderedItems is null) { orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, orderBy) @@ -1728,8 +1736,6 @@ namespace Emby.Server.Implementations.Library ? orderedItems!.ThenByDescending(i => i, orderBy) : orderedItems!.ThenBy(i => i, orderBy); // orderedItems is set during the first iteration } - - isFirst = false; } return orderedItems ?? items; @@ -1738,8 +1744,6 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy) { - var isFirst = true; - IOrderedEnumerable<BaseItem>? orderedItems = null; foreach (var (name, sortOrder) in orderBy) @@ -1750,7 +1754,15 @@ namespace Emby.Server.Implementations.Library continue; } - if (isFirst) + if (comparer is RandomComparer) + { + var randomItems = items.ToArray(); + Random.Shared.Shuffle(randomItems); + items = randomItems; + // Items are no longer ordered at this point, so set orderedItems back to null + orderedItems = null; + } + else if (orderedItems is null) { orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, comparer) @@ -1762,8 +1774,6 @@ namespace Emby.Server.Implementations.Library ? orderedItems!.ThenByDescending(i => i, comparer) : orderedItems!.ThenBy(i => i, comparer); // orderedItems is set during the first iteration } - - isFirst = false; } return orderedItems ?? items; diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 5ec333cb1..bb22ca82f 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -113,6 +113,11 @@ namespace Emby.Server.Implementations.Library return true; } + if (stream.IsPgsSubtitleStream) + { + return true; + } + return false; } diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 845dce5df..9172af516 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -52,7 +52,7 @@ "UserDownloadingItemWithValues": "{0} спампоўваецца {1}", "TaskOptimizeDatabase": "Аптымізаваць базу дадзеных", "Artists": "Выканаўцы", - "UserOfflineFromDevice": "{0} адключыўся ад {1}", + "UserOfflineFromDevice": "{0} адлучыўся ад {1}", "UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}", "TaskCleanActivityLogDescription": "Выдаляе старэйшыя за зададзены ўзрост запісы ў журнале актыўнасці.", "TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.", @@ -66,7 +66,7 @@ "AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}", "Books": "Кнігі", "CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}", - "DeviceOfflineWithName": "{0} адключыўся", + "DeviceOfflineWithName": "{0} адлучыўся", "DeviceOnlineWithName": "{0} падлучаны", "Forced": "Прымусова", "HeaderRecordingGroups": "Групы запісаў", diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index 1f982feaf..a25099ee0 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -104,7 +104,7 @@ "Forced": "ਮਜਬੂਰ", "Folders": "ਫੋਲਡਰ", "Favorites": "ਮਨਪਸੰਦ", - "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ", + "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ", "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", "Default": "ਡਿਫੌਲਟ", @@ -119,5 +119,6 @@ "AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}", "Albums": "ਐਲਬਮਾਂ", "TaskOptimizeDatabase": "ਡਾਟਾਬੇਸ ਅਨੁਕੂਲ ਬਣਾਓ", - "External": "ਬਾਹਰੀ" + "External": "ਬਾਹਰੀ", + "HearingImpaired": "ਸੁਨਣ ਵਿਚ ਕਮਜ਼ੋਰ" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index eff0196d7..3eb1e0468 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -31,7 +31,7 @@ "ItemRemovedWithName": "{0} - изъято из медиатеки", "LabelIpAddressValue": "IP-адрес: {0}", "LabelRunningTimeValue": "Длительность: {0}", - "Latest": "Последние добавленные", + "Latest": "Последние", "MessageApplicationUpdated": "Jellyfin Server был обновлён", "MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена", diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 3cdf743d5..da32e9776 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -123,5 +123,7 @@ "External": "ภายนอก", "HearingImpaired": "บกพร่องทางการได้ยิน", "TaskKeyframeExtractor": "ตัวแยกคีย์เฟรม", - "TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน" + "TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน", + "TaskRefreshTrickplayImages": "สร้างไฟล์รูปภาพสำหรับ Trickplay", + "TaskRefreshTrickplayImagesDescription": "สร้างภาพตัวอย่างของวีดีโอในคลังที่เปิดใช้งาน Trickplay" } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index df0fdcab8..301c04915 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -29,7 +29,7 @@ public partial class AudioNormalizationTask : IScheduledTask private readonly IItemRepository _itemRepository; private readonly ILibraryManager _libraryManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IConfigurationManager _configurationManager; + private readonly IApplicationPaths _applicationPaths; private readonly ILocalizationManager _localization; private readonly ILogger<AudioNormalizationTask> _logger; @@ -39,21 +39,21 @@ public partial class AudioNormalizationTask : IScheduledTask /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param> public AudioNormalizationTask( IItemRepository itemRepository, ILibraryManager libraryManager, IMediaEncoder mediaEncoder, - IConfigurationManager configurationManager, + IApplicationPaths applicationPaths, ILocalizationManager localizationManager, ILogger<AudioNormalizationTask> logger) { _itemRepository = itemRepository; _libraryManager = libraryManager; _mediaEncoder = mediaEncoder; - _configurationManager = configurationManager; + _applicationPaths = applicationPaths; _localization = localizationManager; _logger = logger; } @@ -107,7 +107,9 @@ public partial class AudioNormalizationTask : IScheduledTask } _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id); - var tempFile = Path.Join(_configurationManager.GetTranscodePath(), a.Id + ".concat"); + var tempDir = _applicationPaths.TempDirectory; + Directory.CreateDirectory(tempDir); + var tempFile = Path.Join(tempDir, a.Id + ".concat"); var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal))); await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false); try diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs index 6cb06d31c..254500ccd 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs @@ -62,16 +62,17 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] { new TaskTriggerInfo { + Type = TaskTriggerInfo.TriggerStartup + }, + new TaskTriggerInfo + { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs index d65ac2e5e..9425b47d0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs @@ -27,45 +27,31 @@ namespace Emby.Server.Implementations.ScheduledTasks.Triggers TaskOptions = taskOptions; } - /// <summary> - /// Occurs when [triggered]. - /// </summary> + /// <inheritdoc /> public event EventHandler<EventArgs>? Triggered; - /// <summary> - /// Gets the options of this task. - /// </summary> + /// <inheritdoc /> public TaskOptions TaskOptions { get; } - /// <summary> - /// Stars waiting for the trigger action. - /// </summary> - /// <param name="lastResult">The last result.</param> - /// <param name="logger">The logger.</param> - /// <param name="taskName">The name of the task.</param> - /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> + /// <inheritdoc /> public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup) { DisposeTimer(); + DateTime now = DateTime.UtcNow; DateTime triggerDate; if (lastResult is null) { // Task has never been completed before - triggerDate = DateTime.UtcNow.AddHours(1); + triggerDate = now.AddHours(1); } else { - triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate }.Max().Add(_interval); - } - - if (DateTime.UtcNow > triggerDate) - { - triggerDate = DateTime.UtcNow.AddMinutes(1); + triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate, now.AddMinutes(1) }.Max().Add(_interval); } - var dueTime = triggerDate - DateTime.UtcNow; + var dueTime = triggerDate - now; var maxDueTime = TimeSpan.FromDays(7); if (dueTime > maxDueTime) @@ -76,9 +62,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Triggers _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } - /// <summary> - /// Stops waiting for the trigger action. - /// </summary> + /// <inheritdoc /> public void Stop() { DisposeTimer(); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 429cc542c..329dd2c4c 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1760,6 +1760,12 @@ public class DynamicHlsController : BaseJellyfinApiController { args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } + else if (state.AudioStream?.CodecTag is not null && state.AudioStream.CodecTag.Equals("ac-4", StringComparison.Ordinal)) + { + // ac-4 audio tends to hava a super weird sample rate that will fail most encoders + // force resample it to 48KHz + args += " -ar 48000"; + } args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 908794512..fe7353496 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -17,6 +17,7 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -139,6 +140,8 @@ public class UniversalAudioController : BaseJellyfinApiController // set device specific data foreach (var sourceInfo in info.MediaSources) { + sourceInfo.TranscodingContainer = transcodingContainer; + sourceInfo.TranscodingSubProtocol = transcodingProtocol ?? sourceInfo.TranscodingSubProtocol; _mediaInfoHelper.SetDeviceSpecificData( item, sourceInfo, @@ -173,6 +176,8 @@ public class UniversalAudioController : BaseJellyfinApiController return Redirect(mediaSource.Path); } + // This one is currently very misleading as the SupportsDirectStream actually means "can direct play" + // The definition of DirectStream also seems changed during development var isStatic = mediaSource.SupportsDirectStream; if (!isStatic && mediaSource.TranscodingSubProtocol == MediaStreamProtocol.hls) { @@ -180,20 +185,25 @@ public class UniversalAudioController : BaseJellyfinApiController // ffmpeg option -> file extension // mpegts -> ts // fmp4 -> mp4 - // TODO: remove this when we switch back to the segment muxer var supportedHlsContainers = new[] { "ts", "mp4" }; + // fallback to mpegts if device reports some weird value unsupported by hls + var requestedSegmentContainer = Array.Exists( + supportedHlsContainers, + element => string.Equals(element, transcodingContainer, StringComparison.OrdinalIgnoreCase)) ? transcodingContainer : "ts"; + var segmentContainer = Array.Exists( + supportedHlsContainers, + element => string.Equals(element, mediaSource.TranscodingContainer, StringComparison.OrdinalIgnoreCase)) ? mediaSource.TranscodingContainer : requestedSegmentContainer; var dynamicHlsRequestDto = new HlsAudioRequestDto { Id = itemId, Container = ".m3u8", Static = isStatic, PlaySessionId = info.PlaySessionId, - // fallback to mpegts if device reports some weird value unsupported by hls - SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", + SegmentContainer = segmentContainer, MediaSourceId = mediaSourceId, DeviceId = deviceId, - AudioCodec = audioCodec, + AudioCodec = mediaSource.TranscodeReasons == TranscodeReason.ContainerNotSupported ? "copy" : audioCodec, EnableAutoStreamCopy = true, AllowAudioStreamCopy = true, AllowVideoStreamCopy = true, diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index f8d89119a..6f040cfae 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -151,6 +151,14 @@ public class DynamicHlsHelper var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); + // from universal audio service, need to override the AudioCodec when the actual request differs from original query + if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase)) + { + var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString()); + newQuery["AudioCodec"] = state.OutputAudioCodec; + queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery); + } + // from universal audio service if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer) && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase)) @@ -714,6 +722,21 @@ public class DynamicHlsHelper return HlsCodecStringHelpers.GetAv1String(profile, level, false, bitDepth); } + // VP9 HLS is for video remuxing only, everything is probed from the original video + if (string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) + { + var width = state.VideoStream.Width ?? 0; + var height = state.VideoStream.Height ?? 0; + var framerate = state.VideoStream.AverageFrameRate ?? 30; + var bitDepth = state.VideoStream.BitDepth ?? 8; + return HlsCodecStringHelpers.GetVp9String( + width, + height, + state.VideoStream.PixelFormat, + framerate, + bitDepth); + } + return string.Empty; } diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index ec67b4c1b..d0bfa1fbe 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -183,6 +183,68 @@ public static class HlsCodecStringHelpers } /// <summary> + /// Gets a VP9 codec string. + /// </summary> + /// <param name="width">Video width.</param> + /// <param name="height">Video height.</param> + /// <param name="pixelFormat">Video pixel format.</param> + /// <param name="framerate">Video framerate.</param> + /// <param name="bitDepth">Video bitDepth.</param> + /// <returns>The VP9 codec string.</returns> + public static string GetVp9String(int width, int height, string pixelFormat, float framerate, int bitDepth) + { + // refer: https://www.webmproject.org/vp9/mp4/ + StringBuilder result = new StringBuilder("vp09", 13); + + var profileString = pixelFormat switch + { + "yuv420p" => "00", + "yuvj420p" => "00", + "yuv422p" => "01", + "yuv444p" => "01", + "yuv420p10le" => "02", + "yuv420p12le" => "02", + "yuv422p10le" => "03", + "yuv422p12le" => "03", + "yuv444p10le" => "03", + "yuv444p12le" => "03", + _ => "00" + }; + + var lumaPictureSize = width * height; + var lumaSampleRate = lumaPictureSize * framerate; + var levelString = lumaPictureSize switch + { + <= 0 => "00", + <= 36864 => "10", + <= 73728 => "11", + <= 122880 => "20", + <= 245760 => "21", + <= 552960 => "30", + <= 983040 => "31", + <= 2228224 => lumaSampleRate <= 83558400 ? "40" : "41", + <= 8912896 => lumaSampleRate <= 311951360 ? "50" : (lumaSampleRate <= 588251136 ? "51" : "52"), + <= 35651584 => lumaSampleRate <= 1176502272 ? "60" : (lumaSampleRate <= 4706009088 ? "61" : "62"), + _ => "00" // This should not happen + }; + + if (bitDepth != 8 + && bitDepth != 10 + && bitDepth != 12) + { + // Default to 8 bits + bitDepth = 8; + } + + result.Append('.').Append(profileString).Append('.').Append(levelString); + var bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture); + result.Append('.') + .Append(bitDepthD2); + + return result.ToString(); + } + + /// <summary> /// Gets an AV1 codec string. /// </summary> /// <param name="profile">AV1 profile.</param> diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index efdfc745f..bb32b7c20 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -168,6 +168,7 @@ public class TrickplayManager : ITrickplayManager options.ProcessThreads, options.Qscale, options.ProcessPriority, + options.EnableKeyFrameOnlyExtraction, _encodingHelper, cancellationToken).ConfigureAwait(false); @@ -230,7 +231,7 @@ public class TrickplayManager : ITrickplayManager throw new ArgumentException("Can't create trickplay from 0 images."); } - var workDir = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString("N")); + var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(workDir); var trickplayInfo = new TrickplayInfo diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs index 5311a30e4..5518d6ba8 100644 --- a/Jellyfin.Server/Helpers/StartupHelpers.cs +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -60,6 +60,7 @@ public static class StartupHelpers logger.LogInformation("Log directory path: {LogDirectoryPath}", appPaths.LogDirectoryPath); logger.LogInformation("Config directory path: {ConfigurationDirectoryPath}", appPaths.ConfigurationDirectoryPath); logger.LogInformation("Cache path: {CachePath}", appPaths.CachePath); + logger.LogInformation("Temp directory path: {TempDirPath}", appPaths.TempDirectory); logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath); logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 8bd4fb4f3..68ae67d05 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -2497,11 +2497,6 @@ namespace MediaBrowser.Controller.Entities return new[] { Id }; } - public virtual List<ExternalUrl> GetRelatedUrls() - { - return new List<ExternalUrl>(); - } - public virtual double? GetRefreshProgress() { return null; diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index ede544eec..710b05e7f 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -121,23 +121,5 @@ namespace MediaBrowser.Controller.Entities.Movies return hasChanges; } - - /// <inheritdoc /> - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 37e241414..5c54f014c 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -344,22 +344,5 @@ namespace MediaBrowser.Controller.Entities.TV return hasChanges; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/episodes/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 6297b67e4..a324f79ef 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -350,10 +350,17 @@ namespace MediaBrowser.Controller.Entities.TV public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options, bool shouldIncludeMissingEpisodes) { + var queryFromSeries = ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons; + + // add optimization when this setting is not enabled + var seriesKey = queryFromSeries ? + GetUniqueSeriesKey(this) : + GetUniqueSeriesKey(parentSeason); + var query = new InternalItemsQuery(user) { - AncestorWithPresentationUniqueKey = null, - SeriesPresentationUniqueKey = GetUniqueSeriesKey(this), + AncestorWithPresentationUniqueKey = queryFromSeries ? null : seriesKey, + SeriesPresentationUniqueKey = queryFromSeries ? seriesKey : null, IncludeItemTypes = new[] { BaseItemKind.Episode }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options @@ -482,22 +489,5 @@ namespace MediaBrowser.Controller.Entities.TV return hasChanges; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/shows/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs index 81d50bbc1..939709215 100644 --- a/MediaBrowser.Controller/Entities/Trailer.cs +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -80,22 +80,5 @@ namespace MediaBrowser.Controller.Entities return hasChanges; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index 05540d490..2ac6f9963 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -254,25 +254,5 @@ namespace MediaBrowser.Controller.LiveTv return name; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - if (IsMovie) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId) - }); - } - } - - return list; - } } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 2c35f0a1e..42b09a29e 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -120,7 +120,8 @@ namespace MediaBrowser.Controller.MediaEncoding private static readonly Dictionary<string, string> _mjpegCodecMap = new(StringComparer.OrdinalIgnoreCase) { { "vaapi", _defaultMjpegEncoder + "_vaapi" }, - { "qsv", _defaultMjpegEncoder + "_qsv" } + { "qsv", _defaultMjpegEncoder + "_qsv" }, + { "videotoolbox", _defaultMjpegEncoder + "_videotoolbox" } }; public static readonly string[] LosslessAudioCodecs = new string[] @@ -276,6 +277,21 @@ namespace MediaBrowser.Controller.MediaEncoding && _mediaEncoder.SupportsFilter("scale_vt"); } + private bool IsSwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) + { + if (state.VideoStream is null + || !options.EnableTonemapping + || GetVideoColorBitDepth(state) != 10 + || !_mediaEncoder.SupportsFilter("tonemapx") + || !(string.Equals(state.VideoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) || string.Equals(state.VideoStream?.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return state.VideoStream.VideoRange == VideoRange.HDR + && state.VideoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG or VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithHLG; + } + private bool IsHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream is null @@ -1188,10 +1204,14 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) { - var tmpConcatPath = Path.Join(_configurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat"); - _mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath); + var concatFilePath = Path.Join(_configurationManager.CommonApplicationPaths.CachePath, "concat", state.MediaSource.Id + ".concat"); + if (!File.Exists(concatFilePath)) + { + _mediaEncoder.GenerateConcatConfig(state.MediaSource, concatFilePath); + } + arg.Append(" -f concat -safe 0 -i \"") - .Append(tmpConcatPath) + .Append(concatFilePath) .Append("\" "); } else @@ -3530,6 +3550,7 @@ namespace MediaBrowser.Controller.MediaEncoding 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 doToneMap = IsSwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -3538,7 +3559,7 @@ namespace MediaBrowser.Controller.MediaEncoding /* Make main filters for video stream */ var mainFilters = new List<string>(); - mainFilters.Add(GetOverwriteColorPropertiesParam(state, false)); + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doToneMap)); // INPUT sw surface(memory/copy-back from vram) // sw deint @@ -3561,11 +3582,31 @@ namespace MediaBrowser.Controller.MediaEncoding // sw scale mainFilters.Add(swScaleFilter); - mainFilters.Add("format=" + outFormat); - // sw tonemap <= TODO: finsh the fast tonemap filter + // sw tonemap <= TODO: finish dovi tone mapping + + if (doToneMap) + { + var tonemapArgs = $"tonemapx=tonemap={options.TonemappingAlgorithm}:desat={options.TonemappingDesat}:peak={options.TonemappingPeak}:t=bt709:m=bt709:p=bt709:format={outFormat}"; - // OUTPUT yuv420p/nv12 surface(memory) + if (options.TonemappingParam != 0) + { + tonemapArgs += $":param={options.TonemappingParam}"; + } + + if (string.Equals(options.TonemappingRange, "tv", StringComparison.OrdinalIgnoreCase) + || string.Equals(options.TonemappingRange, "pc", StringComparison.OrdinalIgnoreCase)) + { + tonemapArgs += $":range={options.TonemappingRange}"; + } + + mainFilters.Add(tonemapArgs); + } + else + { + // OUTPUT yuv420p/nv12 surface(memory) + mainFilters.Add("format=" + outFormat); + } /* Make sub and overlay filters for subtitle stream */ var subFilters = new List<string>(); diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index 26c353a54..038c6c7f6 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -153,6 +153,7 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="threads">The input/output thread count for ffmpeg.</param> /// <param name="qualityScale">The qscale value for ffmpeg.</param> /// <param name="priority">The process priority for the ffmpeg process.</param> + /// <param name="enableKeyFrameOnlyExtraction">Whether to only extract key frames.</param> /// <param name="encodingHelper">EncodingHelper instance.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Directory where images where extracted. A given image made before another will always be named with a lower number.</returns> @@ -168,6 +169,7 @@ namespace MediaBrowser.Controller.MediaEncoding int? threads, int? qualityScale, ProcessPriorityClass? priority, + bool enableKeyFrameOnlyExtraction, EncodingHelper encodingHelper, CancellationToken cancellationToken); diff --git a/MediaBrowser.Controller/Providers/IExternalId.cs b/MediaBrowser.Controller/Providers/IExternalId.cs index 0d847520d..f451eac6d 100644 --- a/MediaBrowser.Controller/Providers/IExternalId.cs +++ b/MediaBrowser.Controller/Providers/IExternalId.cs @@ -1,3 +1,4 @@ +using System; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -33,6 +34,7 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Gets the URL format string for this id. /// </summary> + [Obsolete("Obsolete in 10.10, to be removed in 10.11")] string? UrlFormatString { get; } /// <summary> diff --git a/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs b/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs new file mode 100644 index 000000000..86a180627 --- /dev/null +++ b/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Providers; + +/// <summary> +/// Interface to include related urls for an item. +/// </summary> +public interface IExternalUrlProvider +{ + /// <summary> + /// Gets the external service name. + /// </summary> + string Name { get; } + + /// <summary> + /// Get the list of external urls. + /// </summary> + /// <param name="item">The item to get external urls for.</param> + /// <returns>The list of external urls.</returns> + IEnumerable<string> GetExternalUrls(BaseItem item); +} diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index b52f16edc..38fc5f2cc 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -99,12 +99,14 @@ namespace MediaBrowser.Controller.Providers /// <param name="metadataProviders">Metadata providers to use.</param> /// <param name="metadataSavers">Metadata savers to use.</param> /// <param name="externalIds">External IDs to use.</param> + /// <param name="externalUrlProviders">The list of external url providers.</param> void AddParts( IEnumerable<IImageProvider> imageProviders, IEnumerable<IMetadataService> metadataServices, IEnumerable<IMetadataProvider> metadataProviders, IEnumerable<IMetadataSaver> metadataSavers, - IEnumerable<IExternalId> externalIds); + IEnumerable<IExternalId> externalIds, + IEnumerable<IExternalUrlProvider> externalUrlProviders); /// <summary> /// Gets the available remote images. diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs index 8ebb59c59..5bae4fbd5 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -86,7 +86,7 @@ public class BdInfoExaminer : IBlurayExaminer if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0) { // Get the files in the playlist - outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.Name).ToArray(); + outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.FileInfo.FullName).ToArray(); } return outputStream; diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index ec39f159e..a865b0e4c 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -27,6 +27,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "msmpeg4", "dca", "ac3", + "ac4", "aac", "mp3", "flac", @@ -94,6 +95,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "h264_v4l2m2m", "h264_videotoolbox", "hevc_videotoolbox", + "mjpeg_videotoolbox", "h264_rkmpp", "hevc_rkmpp" }; @@ -103,6 +105,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // sw "alphasrc", "zscale", + "tonemapx", // qsv "scale_qsv", "vpp_qsv", @@ -498,6 +501,11 @@ namespace MediaBrowser.MediaEncoding.Encoder return output.Contains(keyDesc, StringComparison.Ordinal); } + public bool CheckSupportedHwaccelFlag(string flag) + { + return !string.IsNullOrEmpty(flag) && GetProcessExitCode(_encoderPath, $"-loglevel quiet -hwaccel_flags +{flag} -hide_banner -f lavfi -i nullsrc=s=1x1:d=100 -f null -"); + } + private IEnumerable<string> GetCodecs(Codec codec) { string codecstr = codec == Codec.Encoder ? "encoders" : "decoders"; @@ -603,6 +611,31 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + private bool GetProcessExitCode(string path, string arguments) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo(path, arguments) + { + CreateNoWindow = true, + UseShellExecute = false, + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }; + _logger.LogDebug("Running {Path} {Arguments}", path, arguments); + + try + { + process.Start(); + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + _logger.LogError("Running {Path} {Arguments} failed with exception {Exception}", path, arguments, ex.Message); + return false; + } + } + [GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)] private static partial Regex CodecRegex(); diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index d0d41c2d3..5cfead502 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -74,6 +74,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private IDictionary<int, bool> _filtersWithOption = new Dictionary<int, bool>(); private bool _isPkeyPauseSupported = false; + private bool _isLowPriorityHwDecodeSupported = false; private bool _isVaapiDeviceAmd = false; private bool _isVaapiDeviceInteliHD = false; @@ -194,6 +195,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _threads = EncodingHelper.GetNumberOfThreads(null, options, null); _isPkeyPauseSupported = validator.CheckSupportedRuntimeKey("p pause transcoding"); + _isLowPriorityHwDecodeSupported = validator.CheckSupportedHwaccelFlag("low_priority"); // Check the Vaapi device vendor if (OperatingSystem.IsLinux() @@ -708,16 +710,22 @@ namespace MediaBrowser.MediaEncoding.Encoder filters.Add("thumbnail=n=" + (useLargerBatchSize ? "50" : "24")); } - // Use SW tonemap on HDR10/HLG video stream only when the zscale filter is available. + // Use SW tonemap on HDR10/HLG video stream only when the zscale or tonemapx filter is available. var enableHdrExtraction = false; - if ((string.Equals(videoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) + if (string.Equals(videoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream?.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) - && SupportsFilter("zscale")) { - enableHdrExtraction = true; - - filters.Add("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p"); + if (SupportsFilter("tonemapx")) + { + enableHdrExtraction = true; + filters.Add("tonemapx=tonemap=bt2390:desat=0:peak=100:t=bt709:m=bt709:p=bt709:format=yuv420p"); + } + else if (SupportsFilter("zscale")) + { + enableHdrExtraction = true; + filters.Add("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p"); + } } var vf = string.Join(',', filters); @@ -807,12 +815,28 @@ namespace MediaBrowser.MediaEncoding.Encoder int? threads, int? qualityScale, ProcessPriorityClass? priority, + bool enableKeyFrameOnlyExtraction, EncodingHelper encodingHelper, CancellationToken cancellationToken) { var options = allowHwAccel ? _configurationManager.GetEncodingOptions() : new EncodingOptions(); threads ??= _threads; + 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); + if (!supportsKeyFrameOnly) + { + // Disable hardware acceleration when the hardware decoder does not support keyframe only mode. + allowHwAccel = false; + options = new EncodingOptions(); + } + } + // A new EncodingOptions instance must be used as to not disable HW acceleration for all of Jellyfin. // Additionally, we must set a few fields without defaults to prevent null pointer exceptions. if (!allowHwAccel) @@ -862,6 +886,17 @@ 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) + { + // VideoToolbox supports low priority decoding, which is useful for trickplay + inputArg = "-hwaccel_flags +low_priority " + inputArg; + } + + if (enableKeyFrameOnlyExtraction) + { + inputArg = "-skip_frame nokey " + inputArg; + } + var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim(); if (string.IsNullOrWhiteSpace(filterParam)) { @@ -894,6 +929,14 @@ namespace MediaBrowser.MediaEncoding.Encoder encoderQuality = (100 - ((qualityScale - 1) * (100 / 30))) / 118; } + 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)); + } + // Output arguments var targetDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(targetDirectory); @@ -902,12 +945,13 @@ 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}-f {5} \"{6}\"", + "-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}{5}-f {6} \"{7}\"", inputArg, filterParam, outputThreads.GetValueOrDefault(_threads), vidEncoder, qualityScale.HasValue ? "-qscale:v " + encoderQuality.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty, + vidEncoder.Contains("videotoolbox", StringComparison.InvariantCultureIgnoreCase) ? "-allow_sw 1 " : string.Empty, // allow_sw fallback for some intel macs "image2", outputPath); @@ -1149,18 +1193,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <inheritdoc /> public IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path) - { - // Get all playable .m2ts files - var validPlaybackFiles = _blurayExaminer.GetDiscInfo(path).Files; - - // Get all files from the BDMV/STREAMING directory - // Only return playable local .m2ts files - var files = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM")).ToList(); - return validPlaybackFiles - .Select(validFile => files.FirstOrDefault(f => Path.GetFileName(f.FullName.AsSpan()).Equals(validFile, StringComparison.OrdinalIgnoreCase))?.FullName) - .Where(f => f is not null) - .ToList(); - } + => _blurayExaminer.GetDiscInfo(path).Files; /// <inheritdoc /> public string GetInputPathArgument(EncodingJobInfo state) @@ -1171,8 +1204,8 @@ namespace MediaBrowser.MediaEncoding.Encoder { return mediaSource.VideoType switch { - VideoType.Dvd => GetInputArgument(GetPrimaryPlaylistVobFiles(path, null).ToList(), mediaSource), - VideoType.BluRay => GetInputArgument(GetPrimaryPlaylistM2tsFiles(path).ToList(), mediaSource), + VideoType.Dvd => GetInputArgument(GetPrimaryPlaylistVobFiles(path, null), mediaSource), + VideoType.BluRay => GetInputArgument(GetPrimaryPlaylistM2tsFiles(path), mediaSource), _ => GetInputArgument(path, mediaSource) }; } @@ -1197,6 +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); foreach (var path in files) { @@ -1216,7 +1250,7 @@ namespace MediaBrowser.MediaEncoding.Encoder var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds; // Add file path stanza to concat configuration - sw.WriteLine("file '{0}'", path); + sw.WriteLine("file '{0}'", path.Replace("'", @"'\''", StringComparison.Ordinal)); // Add duration stanza to concat configuration sw.WriteLine("duration {0}", duration); diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 8b2685fe1..c8bd1eb7f 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -721,6 +721,8 @@ namespace MediaBrowser.MediaEncoding.Probing if (streamInfo.CodecType == CodecType.Audio) { stream.Type = MediaStreamType.Audio; + stream.LocalizedDefault = _localization.GetLocalizedString("Default"); + stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.Channels = streamInfo.Channels; @@ -1319,23 +1321,38 @@ namespace MediaBrowser.MediaEncoding.Probing // These support multiple values, but for now we only store the first. var mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Artist Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMARTISTID")); - audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Artist Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ARTISTID")); - audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMID")); - audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Group Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASEGROUPID")); - audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Track Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASETRACKID")); - audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb); + } } private string GetMultipleMusicBrainzId(string value) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 16837d98b..9ecbfa9cf 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -198,10 +198,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles { if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false); + await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false); - var outputFormat = GetTextSubtitleFormat(subtitleStream); - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat); + var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream); + var outputFormat = GetExtractableSubtitleFormat(subtitleStream); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension); return new SubtitleInfo() { @@ -215,6 +216,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec) .TrimStart('.'); + // Handle PGS subtitles as raw streams for the client to render + if (MediaStream.IsPgsFormat(currentFormat)) + { + return new SubtitleInfo() + { + Path = subtitleStream.Path, + Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path), + Format = "pgssub", + IsExternal = true + }; + } + // Fallback to ffmpeg conversion if (!_subtitleParser.SupportsFileExtension(currentFormat)) { @@ -428,10 +441,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } - private string GetTextSubtitleFormat(MediaStream subtitleStream) + private string GetExtractableSubtitleFormat(MediaStream subtitleStream) { if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) - || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)) + || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase) + || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase)) { return subtitleStream.Codec; } @@ -441,21 +455,35 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } + private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream) + { + // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup. + if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase)) + { + return "sup"; + } + else + { + return GetExtractableSubtitleFormat(subtitleStream); + } + } + private bool IsCodecCopyable(string codec) { return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase); + || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase); } /// <summary> - /// Extracts all text subtitles. + /// Extracts all extractable subtitles (text and pgs). /// </summary> /// <param name="mediaSource">The mediaSource.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) + private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) { var locks = new List<IDisposable>(); var extractableStreams = new List<MediaStream>(); @@ -463,11 +491,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles try { var subtitleStreams = mediaSource.MediaStreams - .Where(stream => stream is { IsTextSubtitleStream: true, SupportsExternalStream: true, IsExternal: false }); + .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true, IsExternal: false }); foreach (var subtitleStream in subtitleStreams) { - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream)); var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false); @@ -483,7 +511,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (extractableStreams.Count > 0) { - await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); + await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) @@ -496,7 +524,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - private async Task ExtractAllTextSubtitlesInternal( + private async Task ExtractAllExtractableSubtitlesInternal( MediaSourceInfo mediaSource, List<MediaStream> subtitleStreams, CancellationToken cancellationToken) @@ -510,7 +538,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles foreach (var subtitleStream in subtitleStreams) { - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream)); var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index ea5dbf7f7..67a2dddb8 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -235,15 +235,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable if (delete(job.Path!)) { await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); - if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay) - { - var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat"); - if (File.Exists(concatFilePath)) - { - _logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath); - File.Delete(concatFilePath); - } - } } if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) @@ -419,7 +410,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) { - var concatPath = Path.Join(_serverConfigurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat"); + var concatPath = Path.Join(_appPaths.CachePath, "concat", state.MediaSource.Id + ".concat"); await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); } else @@ -479,6 +470,11 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable : "FFmpeg.DirectStream-"; } + if (state.VideoRequest is null && EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) + { + logFilePrefix = "FFmpeg.Remux-"; + } + var logFilePath = Path.Combine( _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); diff --git a/MediaBrowser.Model/Configuration/TrickplayOptions.cs b/MediaBrowser.Model/Configuration/TrickplayOptions.cs index a151d3429..578bb306a 100644 --- a/MediaBrowser.Model/Configuration/TrickplayOptions.cs +++ b/MediaBrowser.Model/Configuration/TrickplayOptions.cs @@ -19,6 +19,12 @@ public class TrickplayOptions public bool EnableHwEncoding { get; set; } = false; /// <summary> + /// Gets or sets a value indicating whether to only extract key frames. + /// Significantly faster, but is not compatible with all decoders and/or video files. + /// </summary> + public bool EnableKeyFrameOnlyExtraction { get; set; } = false; + + /// <summary> /// Gets or sets the behavior used by trickplay provider on library scan/update. /// </summary> public TrickplayScanBehavior ScanBehavior { get; set; } = TrickplayScanBehavior.NonBlocking; diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index ba958c030..d37528ede 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -25,7 +25,7 @@ namespace MediaBrowser.Model.Dlna private readonly ILogger _logger; private readonly ITranscoderSupport _transcoderSupport; - private static readonly string[] _supportedHlsVideoCodecs = new string[] { "h264", "hevc", "av1" }; + 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" }; @@ -108,7 +108,7 @@ namespace MediaBrowser.Model.Dlna var inputAudioSampleRate = audioStream?.SampleRate; var inputAudioBitDepth = audioStream?.BitDepth; - if (directPlayMethod.HasValue) + if (directPlayMethod is PlayMethod.DirectPlay) { var profile = options.Profile; var audioFailureConditions = GetProfileConditionsForAudio(profile.CodecProfiles, item.Container, audioStream?.Codec, inputAudioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, true); @@ -124,6 +124,46 @@ namespace MediaBrowser.Model.Dlna } } + if (directPlayMethod is PlayMethod.DirectStream) + { + var remuxContainer = item.TranscodingContainer ?? "ts"; + var supportedHlsContainers = new[] { "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; + bool codeIsSupported; + if (item.TranscodingSubProtocol == MediaStreamProtocol.hls) + { + // Enforce HLS audio codec restrictions + if (string.Equals(remuxContainer, "mp4", StringComparison.OrdinalIgnoreCase)) + { + codeIsSupported = _supportedHlsAudioCodecsMp4.Contains(directPlayInfo.Profile?.AudioCodec ?? directPlayInfo.Profile?.Container); + } + else + { + codeIsSupported = _supportedHlsAudioCodecsTs.Contains(directPlayInfo.Profile?.AudioCodec ?? directPlayInfo.Profile?.Container); + } + } + else + { + // Let's assume the client has given a correct container for http + codeIsSupported = true; + } + + if (codeIsSupported) + { + playlistItem.PlayMethod = directPlayMethod.Value; + playlistItem.Container = remuxContainer; + playlistItem.TranscodeReasons = transcodeReasons; + playlistItem.SubProtocol = item.TranscodingSubProtocol; + item.TranscodingContainer = remuxContainer; + return playlistItem; + } + + transcodeReasons |= TranscodeReason.AudioCodecNotSupported; + playlistItem.TranscodeReasons = transcodeReasons; + } + TranscodingProfile? transcodingProfile = null; foreach (var tcProfile in options.Profile.TranscodingProfiles) { @@ -379,6 +419,7 @@ namespace MediaBrowser.Model.Dlna var directPlayProfile = options.Profile.DirectPlayProfiles .FirstOrDefault(x => x.Type == DlnaProfileType.Audio && IsAudioDirectPlaySupported(x, item, audioStream)); + TranscodeReason transcodeReasons = 0; if (directPlayProfile is null) { _logger.LogDebug( @@ -387,14 +428,25 @@ namespace MediaBrowser.Model.Dlna item.Path ?? "Unknown path", audioStream.Codec ?? "Unknown codec"); - return (null, null, GetTranscodeReasonsFromDirectPlayProfile(item, null, audioStream, options.Profile.DirectPlayProfiles)); - } + var directStreamProfile = options.Profile.DirectPlayProfiles + .FirstOrDefault(x => x.Type == DlnaProfileType.Audio && IsAudioDirectStreamSupported(x, item, audioStream)); - TranscodeReason transcodeReasons = 0; + if (directStreamProfile is not null) + { + directPlayProfile = directStreamProfile; + transcodeReasons |= TranscodeReason.ContainerNotSupported; + } + else + { + return (null, null, GetTranscodeReasonsFromDirectPlayProfile(item, null, audioStream, options.Profile.DirectPlayProfiles)); + } + } // The profile describes what the device supports // If device requirements are satisfied then allow both direct stream and direct play - if (item.SupportsDirectPlay) + // Note: As of 10.10 codebase, SupportsDirectPlay is always true because the MediaSourceInfo initializes this key as true + // Need to check additionally for current transcode reasons + if (item.SupportsDirectPlay && transcodeReasons == 0) { if (!IsBitrateLimitExceeded(item, options.GetMaxBitrate(true) ?? 0)) { @@ -414,7 +466,10 @@ namespace MediaBrowser.Model.Dlna { if (!IsBitrateLimitExceeded(item, options.GetMaxBitrate(true) ?? 0)) { - if (options.EnableDirectStream) + // Note: as of 10.10 codebase, the options.EnableDirectStream is always false due to + // "direct-stream http streaming is currently broken" + // Don't check that option for audio as we always assume that is supported + if (transcodeReasons == TranscodeReason.ContainerNotSupported) { return (directPlayProfile, PlayMethod.DirectStream, transcodeReasons); } @@ -2130,5 +2185,24 @@ namespace MediaBrowser.Model.Dlna return true; } + + private static bool IsAudioDirectStreamSupported(DirectPlayProfile profile, MediaSourceInfo item, MediaStream audioStream) + { + // Check container type, this should NOT be supported + // If the container is supported, the file should be directly played + if (!profile.SupportsContainer(item.Container)) + { + // Check audio codec, we cannot use the SupportsAudioCodec here + // Because that one assumes empty container supports all codec, which is just useless + string? audioCodec = audioStream?.Codec; + if (string.Equals(profile.AudioCodec, audioCodec, StringComparison.OrdinalIgnoreCase) || + string.Equals(profile.Container, audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } } } diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index e1082adea..dcb3febbd 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -7,6 +7,7 @@ using System.ComponentModel; using System.Globalization; using System.Linq; using System.Text; +using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Model.Dlna; @@ -585,6 +586,33 @@ namespace MediaBrowser.Model.Entities } } + [JsonIgnore] + public bool IsPgsSubtitleStream + { + get + { + if (Type != MediaStreamType.Subtitle) + { + return false; + } + + if (string.IsNullOrEmpty(Codec) && !IsExternal) + { + return false; + } + + return IsPgsFormat(Codec); + } + } + + /// <summary> + /// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg. + /// All text-based and pgs subtitles can be extracted. + /// </summary> + /// <value><c>true</c> if this is a extractable subtitle steam otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream; + /// <summary> /// Gets or sets a value indicating whether [supports external stream]. /// </summary> @@ -666,6 +694,14 @@ namespace MediaBrowser.Model.Entities && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)); } + public static bool IsPgsFormat(string format) + { + string codec = format ?? string.Empty; + + return codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase); + } + public bool SupportsSubtitleConversionTo(string toCodec) { if (!IsTextSubtitleStream) diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index cf453d62c..1c73091f0 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -111,31 +111,32 @@ namespace MediaBrowser.Model.Entities /// Sets a provider id. /// </summary> /// <param name="instance">The instance.</param> - /// <param name="name">The name.</param> + /// <param name="name">The name, this should not contain a '=' character.</param> /// <param name="value">The value.</param> - public static void SetProviderId(this IHasProviderIds instance, string name, string? value) + /// <remarks>Due to how deserialization from the database works the name can not contain '='.</remarks> + public static void SetProviderId(this IHasProviderIds instance, string name, string value) { ArgumentNullException.ThrowIfNull(instance); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(value); + + // When name contains a '=' it can't be deserialized from the database + if (name.Contains('=', StringComparison.Ordinal)) + { + throw new ArgumentException("Provider id name cannot contain '='", nameof(name)); + } + + // Ensure it exists + instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - // If it's null remove the key from the dictionary - if (string.IsNullOrEmpty(value)) + // Match on internal MetadataProvider enum string values before adding arbitrary providers + if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue)) { - instance.ProviderIds?.Remove(name); + instance.ProviderIds[enumValue] = value; } else { - // Ensure it exists - instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - // Match on internal MetadataProvider enum string values before adding arbitrary providers - if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue)) - { - instance.ProviderIds[enumValue] = value; - } - else - { - instance.ProviderIds[name] = value; - } + instance.ProviderIds[name] = value; } } @@ -149,5 +150,30 @@ namespace MediaBrowser.Model.Entities { instance.SetProviderId(provider.ToString(), value); } + + /// <summary> + /// Removes a provider id. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="name">The name.</param> + public static void RemoveProviderId(this IHasProviderIds instance, string name) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentException.ThrowIfNullOrEmpty(name); + + instance.ProviderIds?.Remove(name); + } + + /// <summary> + /// Removes a provider id. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="provider">The provider.</param> + public static void RemoveProviderId(this IHasProviderIds instance, MetadataProvider provider) + { + ArgumentNullException.ThrowIfNull(instance); + + instance.ProviderIds?.Remove(provider.ToString()); + } } } diff --git a/MediaBrowser.Model/Providers/ExternalIdInfo.cs b/MediaBrowser.Model/Providers/ExternalIdInfo.cs index d026d574f..1f5163aa8 100644 --- a/MediaBrowser.Model/Providers/ExternalIdInfo.cs +++ b/MediaBrowser.Model/Providers/ExternalIdInfo.cs @@ -1,3 +1,5 @@ +using System; + namespace MediaBrowser.Model.Providers { /// <summary> @@ -17,7 +19,9 @@ namespace MediaBrowser.Model.Providers Name = name; Key = key; Type = type; +#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 UrlFormatString = urlFormatString; +#pragma warning restore CS0618 // Type or member is obsolete } /// <summary> @@ -46,6 +50,7 @@ namespace MediaBrowser.Model.Providers /// <summary> /// Gets or sets the URL format string. /// </summary> + [Obsolete("Obsolete in 10.10, to be removed in 10.11")] public string? UrlFormatString { get; set; } } } diff --git a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs index f8a8c727e..1d8767dc1 100644 --- a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs +++ b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs @@ -13,7 +13,6 @@ namespace MediaBrowser.Model.Tasks public const string TriggerDaily = "DailyTrigger"; public const string TriggerWeekly = "WeeklyTrigger"; public const string TriggerInterval = "IntervalTrigger"; - public const string TriggerSystemEvent = "SystemEventTrigger"; public const string TriggerStartup = "StartupTrigger"; /// <summary> diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index f2ca99da6..60d89a51b 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -69,11 +69,12 @@ namespace MediaBrowser.Providers.Manager o.PoolInitialFill = 1; }); - private IImageProvider[] _imageProviders = Array.Empty<IImageProvider>(); - private IMetadataService[] _metadataServices = Array.Empty<IMetadataService>(); - private IMetadataProvider[] _metadataProviders = Array.Empty<IMetadataProvider>(); - private IMetadataSaver[] _savers = Array.Empty<IMetadataSaver>(); - private IExternalId[] _externalIds = Array.Empty<IExternalId>(); + private IImageProvider[] _imageProviders = []; + private IMetadataService[] _metadataServices = []; + private IMetadataProvider[] _metadataProviders = []; + private IMetadataSaver[] _savers = []; + private IExternalId[] _externalIds = []; + private IExternalUrlProvider[] _externalUrlProviders = []; private bool _isProcessingRefreshQueue; private bool _disposed; @@ -132,12 +133,14 @@ namespace MediaBrowser.Providers.Manager IEnumerable<IMetadataService> metadataServices, IEnumerable<IMetadataProvider> metadataProviders, IEnumerable<IMetadataSaver> metadataSavers, - IEnumerable<IExternalId> externalIds) + IEnumerable<IExternalId> externalIds, + IEnumerable<IExternalUrlProvider> externalUrlProviders) { _imageProviders = imageProviders.ToArray(); _metadataServices = metadataServices.OrderBy(i => i.Order).ToArray(); _metadataProviders = metadataProviders.ToArray(); _externalIds = externalIds.OrderBy(i => i.ProviderName).ToArray(); + _externalUrlProviders = externalUrlProviders.OrderBy(i => i.Name).ToArray(); _savers = metadataSavers.ToArray(); } @@ -877,31 +880,35 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public IEnumerable<ExternalUrl> GetExternalUrls(BaseItem item) { - return GetExternalIds(item) +#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 + var legacyExternalIdUrls = GetExternalIds(item) .Select(i => - { - if (string.IsNullOrEmpty(i.UrlFormatString)) { - return null; - } + var urlFormatString = i.UrlFormatString; + if (string.IsNullOrEmpty(urlFormatString) + || !item.TryGetProviderId(i.Key, out var providerId)) + { + return null; + } - var value = item.GetProviderId(i.Key); + return new ExternalUrl + { + Name = i.ProviderName, + Url = string.Format( + CultureInfo.InvariantCulture, + urlFormatString, + providerId) + }; + }) + .OfType<ExternalUrl>(); +#pragma warning restore CS0618 // Type or member is obsolete - if (string.IsNullOrEmpty(value)) - { - return null; - } + var externalUrls = _externalUrlProviders + .SelectMany(p => p + .GetExternalUrls(item) + .Select(externalUrl => new ExternalUrl { Name = p.Name, Url = externalUrl })); - return new ExternalUrl - { - Name = i.ProviderName, - Url = string.Format( - CultureInfo.InvariantCulture, - i.UrlFormatString, - value) - }; - }).Where(i => i is not null) - .Concat(item.GetRelatedUrls())!; // We just filtered out all the nulls + return legacyExternalIdUrls.Concat(externalUrls).OrderBy(u => u.Name); } /// <inheritdoc/> @@ -912,7 +919,9 @@ namespace MediaBrowser.Providers.Manager name: i.ProviderName, key: i.Key, type: i.Type, +#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 urlFormatString: i.UrlFormatString)); +#pragma warning restore CS0618 // Type or member is obsolete } /// <inheritdoc/> diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 9eacfc2b6..0083d4f75 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -325,22 +325,26 @@ namespace MediaBrowser.Providers.MediaInfo audio.NormalizationGain = (float)tags.ReplayGainTrackGain; } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _)) + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzArtistId)) { audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId); } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _)) + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzReleaseArtistId)) { audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId); } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _)) + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzReleaseId)) { audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId); } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _)) + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzReleaseGroupId)) { audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId); } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 1d4e66570..246ba2733 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -124,11 +124,8 @@ namespace MediaBrowser.Providers.MediaInfo // Get BD disc information blurayDiscInfo = GetBDInfo(item.Path); - // Get playable .m2ts files - var m2ts = _mediaEncoder.GetPrimaryPlaylistM2tsFiles(item.Path); - // Return if no playable .m2ts files are found - if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0) + if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0) { _logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe."); return ItemUpdateType.MetadataImport; @@ -138,7 +135,7 @@ namespace MediaBrowser.Providers.MediaInfo mediaInfoResult = await GetMediaInfo( new Video { - Path = m2ts[0] + Path = blurayDiscInfo.Files[0] }, cancellationToken).ConfigureAwait(false); } diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 75963226a..ede93aaa5 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -219,7 +219,7 @@ public class SkiaEncoder : IImageEncoder return path; } - var tempPath = Path.Combine(_appPaths.TempDirectory, string.Concat(Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan()))); + var tempPath = Path.Join(_appPaths.TempDirectory, string.Concat("skia_", Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan()))); var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); Directory.CreateDirectory(directory); File.Copy(path, tempPath, true); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 020e20fb8..612064190 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -18,7 +18,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing public class ProbeResultNormalizerTests { private readonly JsonSerializerOptions _jsonOptions; - private readonly ProbeResultNormalizer _probeResultNormalizer = new ProbeResultNormalizer(new NullLogger<EncoderValidatorTests>(), null); + private readonly ProbeResultNormalizer _probeResultNormalizer = new ProbeResultNormalizer(new NullLogger<EncoderValidatorTests>(), new Mock<ILocalizationManager>().Object); public ProbeResultNormalizerTests() { diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs index 2a62ab74c..a6f416414 100644 --- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs @@ -141,7 +141,7 @@ namespace Jellyfin.Model.Tests.Entities public void SetProviderId_Null_Remove() { var provider = new ProviderIdsExtensionsTestsObject(); - provider.SetProviderId(MetadataProvider.Imdb, null!); + Assert.Throws<ArgumentNullException>(() => provider.SetProviderId(MetadataProvider.Imdb, null!)); Assert.Empty(provider.ProviderIds); } @@ -150,8 +150,8 @@ namespace Jellyfin.Model.Tests.Entities { var provider = new ProviderIdsExtensionsTestsObject(); provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; - provider.SetProviderId(MetadataProvider.Imdb, string.Empty); - Assert.Empty(provider.ProviderIds); + Assert.Throws<ArgumentException>(() => provider.SetProviderId(MetadataProvider.Imdb, string.Empty)); + Assert.Single(provider.ProviderIds); } [Fact] @@ -182,10 +182,20 @@ namespace Jellyfin.Model.Tests.Entities ProviderIds = null! }; - nullProvider.SetProviderId(MetadataProvider.Imdb, string.Empty); + Assert.Throws<ArgumentException>(() => nullProvider.SetProviderId(MetadataProvider.Imdb, string.Empty)); Assert.Null(nullProvider.ProviderIds); } + [Fact] + public void RemoveProviderId_Null_Remove() + { + var provider = new ProviderIdsExtensionsTestsObject(); + + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + provider.RemoveProviderId(MetadataProvider.Imdb); + Assert.Empty(provider.ProviderIds); + } + private sealed class ProviderIdsExtensionsTestsObject : IHasProviderIds { public static readonly ProviderIdsExtensionsTestsObject Empty = new ProviderIdsExtensionsTestsObject(); diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 6fccce049..cced2b1e2 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -585,15 +585,17 @@ namespace Jellyfin.Providers.Tests.Manager IEnumerable<IMetadataService>? metadataServices = null, IEnumerable<IMetadataProvider>? metadataProviders = null, IEnumerable<IMetadataSaver>? metadataSavers = null, - IEnumerable<IExternalId>? externalIds = null) + IEnumerable<IExternalId>? externalIds = null, + IEnumerable<IExternalUrlProvider>? externalUrlProviders = null) { imageProviders ??= Array.Empty<IImageProvider>(); metadataServices ??= Array.Empty<IMetadataService>(); metadataProviders ??= Array.Empty<IMetadataProvider>(); metadataSavers ??= Array.Empty<IMetadataSaver>(); externalIds ??= Array.Empty<IExternalId>(); + externalUrlProviders ??= Array.Empty<IExternalUrlProvider>(); - providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds); + providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds, externalUrlProviders); } /// <summary> |
