mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-07-20 05:04:18 +01:00
Compare commits
69 Commits
renovate/s
...
fix/ffmpeg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d8d53fb1 | ||
|
|
2f9bae965f | ||
|
|
5f5442cb12 | ||
|
|
3321a0cf09 | ||
|
|
dd0b273b26 | ||
|
|
cab108a839 | ||
|
|
3d83e67a52 | ||
|
|
b809d964b3 | ||
|
|
96727072c8 | ||
|
|
9ec0d7c63b | ||
|
|
bfe0a47b32 | ||
|
|
9b5f830462 | ||
|
|
42f9ed76c0 | ||
|
|
69faa6c583 | ||
|
|
a96dc8bd9b | ||
|
|
d146c347b4 | ||
|
|
f2dea2a3fd | ||
|
|
4d1dd42420 | ||
|
|
8eb0ba60c1 | ||
|
|
2bf15d95df | ||
|
|
be2db99586 | ||
|
|
37fe9df27c | ||
|
|
8759ad4d49 | ||
|
|
051ff4cf6d | ||
|
|
fa4626c080 | ||
|
|
4cc577a72d | ||
|
|
2b57a5725c | ||
|
|
5cbafa566d | ||
|
|
0d3cf0169e | ||
|
|
f8771b52ec | ||
|
|
e3d610c3c5 | ||
|
|
486ffaa6dd | ||
|
|
d96d1f1119 | ||
|
|
30f28456de | ||
|
|
f8aad322cc | ||
|
|
c6545a8b68 | ||
|
|
0fe9c1ce91 | ||
|
|
b6882c86dc | ||
|
|
4503ad295c | ||
|
|
1a9bd2417a | ||
|
|
0d2f1b26b3 | ||
|
|
bc59e56ef5 | ||
|
|
b8bac71270 | ||
|
|
6238448716 | ||
|
|
cfeaef6180 | ||
|
|
ebab369eac | ||
|
|
0584a102ee | ||
|
|
2aae53bc15 | ||
|
|
911044b415 | ||
|
|
b77def6883 | ||
|
|
5c48935fa8 | ||
|
|
e2d9d592bc | ||
|
|
305160d2e4 | ||
|
|
933f7eea1b | ||
|
|
631a314d24 | ||
|
|
ed06fd5139 | ||
|
|
38813f7d42 | ||
|
|
853922443f | ||
|
|
8b826d981b | ||
|
|
8bf1710e07 | ||
|
|
5e42941d3b | ||
|
|
a1255bda6a | ||
|
|
9a2fdb3573 | ||
|
|
f3fbe5575a | ||
|
|
860cb75d58 | ||
|
|
95cebffa87 | ||
|
|
617ebf367f | ||
|
|
c49f279a27 | ||
|
|
9569b4550d |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.9",
|
||||
"version": "10.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
8
.github/workflows/ci-codeql-analysis.yml
vendored
8
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -27,18 +27,18 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
|
||||
4
.github/workflows/ci-compat.yml
vendored
4
.github/workflows/ci-compat.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
2
.github/workflows/ci-format.yml
vendored
2
.github/workflows/ci-format.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
|
||||
2
.github/workflows/issue-stale.yml
vendored
2
.github/workflows/issue-stale.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
2
.github/workflows/openapi-generate.yml
vendored
2
.github/workflows/openapi-generate.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
repository: ${{ inputs.repository }}
|
||||
|
||||
- name: Configure .NET
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
2
.github/workflows/pull-request-stale.yaml
vendored
2
.github/workflows/pull-request-stale.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -26,28 +26,28 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
@@ -74,10 +74,10 @@
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="5.1.1" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Devices
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides the persistent unique identifier of this server installation.
|
||||
/// </summary>
|
||||
public class DeviceId
|
||||
{
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
@@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices
|
||||
|
||||
private string? _id;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeviceId"/> class.
|
||||
/// </summary>
|
||||
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param>
|
||||
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
|
||||
{
|
||||
_appPaths = appPaths;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the device id, loading it from disk or generating and persisting a new one if none exists.
|
||||
/// </summary>
|
||||
public string Value => _id ??= GetDeviceId();
|
||||
|
||||
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
|
||||
|
||||
@@ -1385,38 +1385,22 @@ namespace Emby.Server.Implementations.Dto
|
||||
}
|
||||
}
|
||||
|
||||
if (options.PreferEpisodeParentPoster)
|
||||
if (options.GetImageLimit(ImageType.Primary) > 0)
|
||||
{
|
||||
var episodeSeason = episode.Season;
|
||||
var seasonPrimaryTag = episodeSeason is not null
|
||||
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
|
||||
: null;
|
||||
|
||||
BaseItem? posterParent = null;
|
||||
if (seasonPrimaryTag is not null)
|
||||
{
|
||||
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
|
||||
dto.ParentPrimaryImageTag = seasonPrimaryTag;
|
||||
posterParent = episodeSeason;
|
||||
}
|
||||
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
|
||||
{
|
||||
dto.ParentPrimaryImageItemId = episodeSeries.Id;
|
||||
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
|
||||
posterParent = episodeSeries;
|
||||
}
|
||||
|
||||
if (posterParent is not null)
|
||||
{
|
||||
if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag))
|
||||
{
|
||||
// Only drop the episode's own primary blurhash; keep the poster parent's.
|
||||
dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag);
|
||||
}
|
||||
|
||||
dto.SeriesPrimaryImageTag = null;
|
||||
dto.PrimaryImageAspectRatio = null;
|
||||
AttachPrimaryImageAspectRatio(dto, posterParent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -418,10 +418,10 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates each source's own playback position for the user and, when the queried item is a
|
||||
/// primary, moves the most recently played version to the front so that resuming without an
|
||||
/// explicit source selection plays the version that was last watched. A directly queried
|
||||
/// alternate version keeps its own source first.
|
||||
/// When the queried item is a primary, moves the most recently played version to the front so
|
||||
/// that resuming without an explicit source selection plays the version that was last watched.
|
||||
/// A directly queried alternate version keeps its own source first. Per-user playback position
|
||||
/// is not surfaced on the source itself; it is carried by each version's own UserData.
|
||||
/// </summary>
|
||||
/// <param name="item">The queried item.</param>
|
||||
/// <param name="sources">The item's media sources.</param>
|
||||
@@ -451,16 +451,6 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source.Id is not null
|
||||
&& dataBySourceId.TryGetValue(source.Id, out var data)
|
||||
&& data.PlaybackPositionTicks > 0)
|
||||
{
|
||||
source.PlaybackPositionTicks = data.PlaybackPositionTicks;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder only for a resumable (in-progress) version;
|
||||
// a completed version has no position to resume, so it must not be pulled to the front here.
|
||||
var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
|
||||
@@ -53,6 +53,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
|
||||
@@ -60,14 +61,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared query helpers.</param>
|
||||
/// <param name="serverConfigurationManager">The server configuration manager.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public MovieSimilarItemsProvider(
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -156,6 +160,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
|
||||
IsPlayed = false
|
||||
};
|
||||
|
||||
if (query.User is not null)
|
||||
{
|
||||
_libraryManager.ConfigureUserAccess(filter, query.User);
|
||||
}
|
||||
|
||||
_queryHelpers.PrepareFilterQuery(filter);
|
||||
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
|
||||
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
|
||||
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días."
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
|
||||
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
|
||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש"
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||
"Original": "מקור"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
|
||||
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
|
||||
"CleanupUserDataTask": "用戶資料清理工作",
|
||||
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
|
||||
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
|
||||
"Original": "原作",
|
||||
"LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
// Convert ints directly
|
||||
// This may override some of the locale specific age ratings (but those always map to the same age)
|
||||
if (int.TryParse(rating, out var ratingAge))
|
||||
if (TryParseRatingAsScore(rating, out var ratingAge))
|
||||
{
|
||||
return new(ratingAge, null);
|
||||
}
|
||||
@@ -487,6 +487,13 @@ namespace Emby.Server.Implementations.Localization
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's not a recognized rating string, fall back to using the number as the score
|
||||
if (TryParseRatingAsScore(ratingPart, out var numericScore))
|
||||
{
|
||||
result = new ParentalRatingScore(numericScore, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
|
||||
rating,
|
||||
@@ -501,6 +508,18 @@ namespace Emby.Server.Implementations.Localization
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
|
||||
/// </summary>
|
||||
/// <param name="ratingValue">Rating value to parse.</param>
|
||||
/// <param name="score">Parsed score.</param>
|
||||
/// <returns>Returns true if parsing was successful.</returns>
|
||||
private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
|
||||
{
|
||||
var trimmed = ratingValue.TrimEnd('+');
|
||||
return int.TryParse(trimmed, out score);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetLocalizedString(string phrase)
|
||||
{
|
||||
|
||||
@@ -10,21 +10,42 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K12"],
|
||||
"ratingStrings": ["K12", "K-12", "12"],
|
||||
"ratingScore": {
|
||||
"score": 12,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K13", "K-13", "13"],
|
||||
"ratingScore": {
|
||||
"score": 13,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K15"],
|
||||
"ratingStrings": ["K15", "K-15", "15"],
|
||||
"ratingScore": {
|
||||
"score": 15,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K18"],
|
||||
"ratingStrings": ["K16", "K-16", "16"],
|
||||
"ratingScore": {
|
||||
"score": 16,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K17", "K-17", "17"],
|
||||
"ratingScore": {
|
||||
"score": 17,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K18", "K-18", "18", "18+"],
|
||||
"ratingScore": {
|
||||
"score": 18,
|
||||
"subScore": null
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask
|
||||
{
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = false,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
RedirectStandardError = true
|
||||
},
|
||||
})
|
||||
|
||||
@@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
|
||||
{
|
||||
// TODO: Deprecate with new iOS app
|
||||
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodePath = _serverConfigurationManager.GetTranscodePath();
|
||||
file = Path.GetFullPath(Path.Combine(transcodePath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
|
||||
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
|
||||
if (file is null)
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
@@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
|
||||
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
|
||||
{
|
||||
var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodePath = _serverConfigurationManager.GetTranscodePath();
|
||||
file = Path.GetFullPath(Path.Combine(transcodePath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
|
||||
|| Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())));
|
||||
if (file is null
|
||||
|| !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
@@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
[FromRoute, Required] string segmentId,
|
||||
[FromRoute, Required] string segmentContainer)
|
||||
{
|
||||
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
|
||||
|
||||
file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
|
||||
var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
|
||||
if (file is null)
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
|
||||
var normalizedPlaylistId = playlistId;
|
||||
|
||||
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
|
||||
var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
|
||||
// Add . to start of segment container for future use.
|
||||
segmentContainer = segmentContainer.Insert(0, ".");
|
||||
@@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
var pathExtension = Path.GetExtension(path);
|
||||
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
&& path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase))
|
||||
&& path.Contains(playlistId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlistPath = path;
|
||||
break;
|
||||
@@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController
|
||||
: GetFileResult(file, playlistPath);
|
||||
}
|
||||
|
||||
private string? ValidateTranscodePath(string filename)
|
||||
{
|
||||
var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath()));
|
||||
var file = Path.GetFullPath(filename, transcodePath);
|
||||
// Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass.
|
||||
if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private ActionResult GetFileResult(string path, string playlistPath)
|
||||
{
|
||||
var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
|
||||
|
||||
@@ -279,6 +279,11 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
|
||||
}
|
||||
|
||||
if (request.SeriesName is not null && item is IHasSeries hasSeries)
|
||||
{
|
||||
hasSeries.SeriesName = request.SeriesName;
|
||||
}
|
||||
|
||||
item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
|
||||
item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
|
||||
item.ProductionYear = request.ProductionYear;
|
||||
@@ -297,6 +302,8 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
{
|
||||
foreach (var season in rseries.Children.OfType<Season>())
|
||||
{
|
||||
season.SeriesName = rseries.Name;
|
||||
|
||||
if (!season.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
season.OfficialRating = request.OfficialRating;
|
||||
@@ -314,6 +321,8 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
|
||||
foreach (var ep in season.Children.OfType<Episode>())
|
||||
{
|
||||
ep.SeriesName = rseries.Name;
|
||||
|
||||
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
ep.OfficialRating = request.OfficialRating;
|
||||
|
||||
@@ -316,7 +316,6 @@ public class ItemsController : BaseJellyfinApiController
|
||||
if (folder is IHasCollectionType hasCollectionType)
|
||||
{
|
||||
collectionType = hasCollectionType.CollectionType;
|
||||
includeItemTypes = [.. includeItemTypes.Union(DtoExtensions.GetBaseItemKindsForCollectionType(collectionType))];
|
||||
}
|
||||
|
||||
if (collectionType == CollectionType.playlists)
|
||||
@@ -329,7 +328,6 @@ public class ItemsController : BaseJellyfinApiController
|
||||
includeItemTypes = collectionType switch
|
||||
{
|
||||
CollectionType.boxsets => [BaseItemKind.BoxSet],
|
||||
null => [BaseItemKind.Movie, BaseItemKind.Series],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,10 +226,13 @@ public class PluginsController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(plugin.Manifest.ImagePath))
|
||||
string? imagePath = plugin.Manifest.ImagePath;
|
||||
if (!string.IsNullOrWhiteSpace(imagePath))
|
||||
{
|
||||
var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
|
||||
if (!System.IO.File.Exists(imagePath))
|
||||
var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path));
|
||||
imagePath = Path.GetFullPath(imagePath, pluginPath);
|
||||
// Require a separator after the plugin path so a sibling like "<pluginPath>-evil" can't pass.
|
||||
if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
@@ -551,8 +551,6 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
dtoOptions.PreferEpisodeParentPoster = true;
|
||||
|
||||
var list = _userViewManager.GetLatestItems(
|
||||
new LatestItemsQuery
|
||||
{
|
||||
|
||||
@@ -631,6 +631,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||
dbContext.Update(user);
|
||||
await dbContext.SaveChangesAsync()
|
||||
.ConfigureAwait(false);
|
||||
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
||||
|
||||
@@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions
|
||||
c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API");
|
||||
c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css");
|
||||
c.RoutePrefix = "api-docs/swagger";
|
||||
c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }""");
|
||||
})
|
||||
.UseReDoc(c =>
|
||||
{
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// The result of a channel item query.
|
||||
/// </summary>
|
||||
public class ChannelItemResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChannelItemResult"/> class.
|
||||
/// </summary>
|
||||
public ChannelItemResult()
|
||||
{
|
||||
Items = Array.Empty<ChannelItemInfo>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the items.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChannelItemInfo> Items { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total record count.
|
||||
/// </summary>
|
||||
public int? TotalRecordCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of a channel item.
|
||||
/// </summary>
|
||||
public enum ChannelItemType
|
||||
{
|
||||
/// <summary>
|
||||
/// The item is a media item.
|
||||
/// </summary>
|
||||
Media = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The item is a folder.
|
||||
/// </summary>
|
||||
Folder = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// The request for a latest media search in a channel.
|
||||
/// </summary>
|
||||
public class ChannelLatestMediaSearch
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the user id.
|
||||
/// </summary>
|
||||
public string UserId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// The parental rating of a channel.
|
||||
/// </summary>
|
||||
public enum ChannelParentalRating
|
||||
{
|
||||
/// <summary>
|
||||
/// Suitable for a general audience.
|
||||
/// </summary>
|
||||
GeneralAudience = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Parental guidance suggested (US PG).
|
||||
/// </summary>
|
||||
UsPG = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Parents strongly cautioned (US PG-13).
|
||||
/// </summary>
|
||||
UsPG13 = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Restricted (US R).
|
||||
/// </summary>
|
||||
UsR = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Suitable for adults only.
|
||||
/// </summary>
|
||||
Adult = 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// The request for a search in a channel.
|
||||
/// </summary>
|
||||
public class ChannelSearchInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the search term.
|
||||
/// </summary>
|
||||
public string SearchTerm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user id.
|
||||
/// </summary>
|
||||
public string UserId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for channels that provide a cache key.
|
||||
/// </summary>
|
||||
public interface IHasCacheKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cache key.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user identifier.</param>
|
||||
/// <returns>System.String.</returns>
|
||||
/// <returns>The cache key.</returns>
|
||||
string? GetCacheKey(string? userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for channels that support deleting items.
|
||||
/// </summary>
|
||||
public interface ISupportsDelete
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the item can be deleted.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <returns><c>true</c> if the item can be deleted, <c>false</c> otherwise.</returns>
|
||||
bool CanDelete(BaseItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the item with the provided id.
|
||||
/// </summary>
|
||||
/// <param name="id">The item id.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the deletion of the item.</returns>
|
||||
Task DeleteItem(string id, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.Channels
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for channels that support retrieving the latest media.
|
||||
/// </summary>
|
||||
public interface ISupportsLatestMedia
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -81,13 +81,6 @@ namespace MediaBrowser.Controller.Dto
|
||||
/// </summary>
|
||||
public bool AddCurrentProgram { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
|
||||
/// image, falling back to the series') should replace the episode's own (16:9) primary image.
|
||||
/// Used by views that render episodes as poster cards, e.g. "Latest".
|
||||
/// </summary>
|
||||
public bool PreferEpisodeParentPoster { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the specified field is populated.
|
||||
/// </summary>
|
||||
|
||||
@@ -492,8 +492,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
|
||||
{
|
||||
return GetAdditionalPartIds()
|
||||
.Select(i => LibraryManager.GetItemById<Video>(i, user))
|
||||
.Where(i => i is not null)
|
||||
.Select(i => LibraryManager.GetItemById<Video>(i))
|
||||
.Where(i => i is not null && (user is null || i.IsVisible(user)))
|
||||
.OrderBy(i => i.SortName);
|
||||
}
|
||||
|
||||
|
||||
@@ -4054,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
mainFilters.Add(swDeintFilter);
|
||||
}
|
||||
|
||||
var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p";
|
||||
var outFormat = doCuTonemap ? "p010le" : "yuv420p";
|
||||
var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
|
||||
// sw scale
|
||||
mainFilters.Add(swScaleFilter);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// The lookup info for books.
|
||||
/// </summary>
|
||||
public class BookInfo : ItemLookupInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the series the book belongs to.
|
||||
/// </summary>
|
||||
public string SeriesName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// The lookup info for box sets.
|
||||
/// </summary>
|
||||
public class BoxSetInfo : ItemLookupInfo
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for items that provide lookup info for metadata providers.
|
||||
/// </summary>
|
||||
/// <typeparam name="TLookupInfoType">The type of the lookup info.</typeparam>
|
||||
public interface IHasLookupInfo<out TLookupInfoType>
|
||||
where TLookupInfoType : ItemLookupInfo, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the lookup info.
|
||||
/// </summary>
|
||||
/// <returns>The lookup info.</returns>
|
||||
TLookupInfoType GetLookupInfo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Marker interface for custom metadata providers that run before the regular metadata refresh.
|
||||
/// </summary>
|
||||
public interface IPreRefreshProvider : ICustomMetadataProvider
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// The lookup info for movies.
|
||||
/// </summary>
|
||||
public class MovieInfo : ItemLookupInfo
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// The lookup info for persons.
|
||||
/// </summary>
|
||||
public class PersonLookupInfo : ItemLookupInfo
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// The lookup info for series.
|
||||
/// </summary>
|
||||
public class SeriesInfo : ItemLookupInfo
|
||||
{
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// The lookup info for trailers.
|
||||
/// </summary>
|
||||
public class TrailerInfo : ItemLookupInfo
|
||||
{
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ namespace MediaBrowser.LocalMetadata.Images
|
||||
if (item.SupportsLocalMetadata)
|
||||
{
|
||||
// Episode has its own provider
|
||||
if (item is Episode || item is Audio || item is Photo)
|
||||
if (item is Episode || (item is Audio && item is not AudioBook) || item is Photo)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -184,8 +185,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
{ "libavdevice", new Version(58, 13) },
|
||||
{ "libavfilter", new Version(7, 110) },
|
||||
{ "libswscale", new Version(5, 9) },
|
||||
{ "libswresample", new Version(3, 9) },
|
||||
{ "libpostproc", new Version(55, 9) }
|
||||
{ "libswresample", new Version(3, 9) }
|
||||
};
|
||||
|
||||
private readonly ILogger _logger;
|
||||
@@ -645,7 +645,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false,
|
||||
RedirectStandardInput = redirectStandardIn,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
RedirectStandardOutput = true,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
RedirectStandardError = true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
@@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
UseShellExecute = false,
|
||||
|
||||
// Must consume both or ffmpeg may hang due to deadlocks.
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
RedirectStandardOutput = true,
|
||||
|
||||
FileName = _ffprobePath,
|
||||
|
||||
@@ -167,7 +167,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
if (fileInfo.Protocol == MediaProtocol.Http)
|
||||
{
|
||||
var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false);
|
||||
var detected = result.Detected;
|
||||
|
||||
if (detected is not null)
|
||||
@@ -445,98 +445,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
encodingParam = " -sub_charenc " + encodingParam;
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
|
||||
|
||||
using (var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
})
|
||||
{
|
||||
_logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting ffmpeg");
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(true);
|
||||
exitCode = -1;
|
||||
}
|
||||
}
|
||||
|
||||
var failed = false;
|
||||
|
||||
if (exitCode == -1)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
_logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
|
||||
|
||||
throw new FfmpegException(
|
||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
|
||||
}
|
||||
|
||||
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
await ExtractSubtitlesForFile(
|
||||
inputPath,
|
||||
args,
|
||||
[outputPath],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
WriteCacheMeta(outputPath, inputPath);
|
||||
|
||||
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
|
||||
}
|
||||
|
||||
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
|
||||
@@ -727,7 +644,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
var outputPaths = new List<string>();
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i {0}",
|
||||
"-y -i {0}",
|
||||
inputPath);
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
@@ -781,50 +698,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
private async Task ExtractSubtitlesForFile(
|
||||
string inputPath,
|
||||
string args,
|
||||
List<string> outputPaths,
|
||||
IReadOnlyList<string> outputPaths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int exitCode;
|
||||
|
||||
using (var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = args,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
})
|
||||
{
|
||||
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting ffmpeg");
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(true);
|
||||
exitCode = -1;
|
||||
}
|
||||
}
|
||||
var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var failed = false;
|
||||
|
||||
@@ -884,6 +761,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
if (failed)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ffmpegError))
|
||||
{
|
||||
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError);
|
||||
}
|
||||
|
||||
throw new FfmpegException(
|
||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
|
||||
}
|
||||
@@ -941,16 +825,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
ArgumentException.ThrowIfNullOrEmpty(outputPath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
|
||||
|
||||
var processArgs = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
|
||||
"-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
|
||||
inputPath,
|
||||
subtitleStreamIndex,
|
||||
outputCodec,
|
||||
outputPath);
|
||||
|
||||
await ExtractSubtitlesForFile(
|
||||
inputPath,
|
||||
processArgs,
|
||||
[outputPath],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// stdin is redirected and closed, and <c>-nostdin</c> is prepended to the arguments, so ffmpeg can never
|
||||
/// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM,
|
||||
/// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer
|
||||
/// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is
|
||||
/// unused for subtitle extraction.
|
||||
/// </remarks>
|
||||
/// <param name="arguments">The ffmpeg command line arguments.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns>
|
||||
private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
int exitCode;
|
||||
var standardError = string.Empty;
|
||||
|
||||
using (var process = new Process
|
||||
{
|
||||
@@ -958,8 +864,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = processArgs,
|
||||
Arguments = "-nostdin " + arguments,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false
|
||||
},
|
||||
@@ -975,14 +883,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting ffmpeg");
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
// Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle.
|
||||
process.StandardInput.Close();
|
||||
|
||||
// Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg.
|
||||
var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
|
||||
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
||||
using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
|
||||
|
||||
try
|
||||
{
|
||||
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
|
||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
|
||||
await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false);
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -990,59 +905,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
process.Kill(true);
|
||||
exitCode = -1;
|
||||
}
|
||||
}
|
||||
|
||||
var failed = false;
|
||||
|
||||
if (exitCode == -1)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
standardError = await standardErrorTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||
// Reading ffmpeg output was cancelled; nothing more to capture.
|
||||
}
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
|
||||
throw new FfmpegException(
|
||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
|
||||
}
|
||||
|
||||
_logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
|
||||
if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return (exitCode, standardError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1104,7 +978,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
}
|
||||
|
||||
var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
|
||||
var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false);
|
||||
var charset = result.Detected?.EncodingName ?? string.Empty;
|
||||
|
||||
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
|
||||
@@ -1120,8 +994,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
return charset;
|
||||
}
|
||||
|
||||
private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
|
||||
private async Task<DetectionResult> DetectCharset(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var protocol = _mediaSourceManager.GetPathProtocol(path);
|
||||
switch (protocol)
|
||||
{
|
||||
case MediaProtocol.Http:
|
||||
@@ -1141,7 +1016,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
|
||||
throw new NotSupportedException($"Unsupported protocol: {protocol}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
||||
|
||||
// Must consume both stdout and stderr or deadlocks may occur
|
||||
// RedirectStandardOutput = true,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Dlna
|
||||
{
|
||||
/// <summary>
|
||||
/// The codec type of a codec profile.
|
||||
/// </summary>
|
||||
public enum CodecType
|
||||
{
|
||||
/// <summary>
|
||||
/// The profile applies to a video codec.
|
||||
/// </summary>
|
||||
Video = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The profile applies to the audio codec of a video stream.
|
||||
/// </summary>
|
||||
VideoAudio = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The profile applies to an audio codec.
|
||||
/// </summary>
|
||||
Audio = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Dlna
|
||||
{
|
||||
/// <summary>
|
||||
/// The encoding context.
|
||||
/// </summary>
|
||||
public enum EncodingContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The media is transcoded on the fly and delivered as a stream.
|
||||
/// </summary>
|
||||
Streaming = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The media is transcoded to a static file.
|
||||
/// </summary>
|
||||
Static = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Dlna
|
||||
{
|
||||
/// <summary>
|
||||
/// The playback error code.
|
||||
/// </summary>
|
||||
public enum PlaybackErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Playback of the item is not allowed.
|
||||
/// </summary>
|
||||
NotAllowed = 0,
|
||||
|
||||
/// <summary>
|
||||
/// No stream compatible with the device profile was found.
|
||||
/// </summary>
|
||||
NoCompatibleStream = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The rate limit has been exceeded.
|
||||
/// </summary>
|
||||
RateLimitExceeded = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Dlna
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolution constraints.
|
||||
/// </summary>
|
||||
public class ResolutionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum width.
|
||||
/// </summary>
|
||||
public int? MaxWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum height.
|
||||
/// </summary>
|
||||
public int? MaxHeight { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Dlna
|
||||
{
|
||||
/// <summary>
|
||||
/// The transcode seek info.
|
||||
/// </summary>
|
||||
public enum TranscodeSeekInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The seek method is chosen automatically.
|
||||
/// </summary>
|
||||
Auto = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Seeking is performed by byte position.
|
||||
/// </summary>
|
||||
Bytes = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,6 @@ namespace MediaBrowser.Model.Dto
|
||||
|
||||
public long? RunTimeTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playback position for this specific source.
|
||||
/// </summary>
|
||||
public long? PlaybackPositionTicks { get; set; }
|
||||
|
||||
public bool ReadAtNativeFramerate { get; set; }
|
||||
|
||||
public bool IgnoreDts { get; set; }
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MediaBrowser.Model.Session
|
||||
{
|
||||
/// <summary>
|
||||
/// A command to display a message on a client.
|
||||
/// </summary>
|
||||
public class MessageCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message header.
|
||||
/// </summary>
|
||||
public string Header { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message text.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout in milliseconds after which the message should be dismissed.
|
||||
/// </summary>
|
||||
public long? TimeoutMs { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Session
|
||||
{
|
||||
/// <summary>
|
||||
/// The play method.
|
||||
/// </summary>
|
||||
public enum PlayMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// The media is transcoded before it is sent to the client.
|
||||
/// </summary>
|
||||
Transcode = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The media is remuxed into a compatible container but the streams are not re-encoded.
|
||||
/// </summary>
|
||||
DirectStream = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The media is sent to the client as-is.
|
||||
/// </summary>
|
||||
DirectPlay = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Session
|
||||
{
|
||||
/// <summary>
|
||||
/// A request to change the playstate of a session.
|
||||
/// </summary>
|
||||
public class PlaystateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the playstate command.
|
||||
/// </summary>
|
||||
public PlaystateCommand Command { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the seek position in ticks.
|
||||
/// </summary>
|
||||
public long? SeekPositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Model.Session;
|
||||
|
||||
/// <summary>
|
||||
/// An item in a play queue.
|
||||
/// </summary>
|
||||
public record QueueItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the item id.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist item id.
|
||||
/// </summary>
|
||||
public string PlaylistItemId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Session
|
||||
{
|
||||
/// <summary>
|
||||
/// The repeat mode of a play queue.
|
||||
/// </summary>
|
||||
public enum RepeatMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Nothing is repeated.
|
||||
/// </summary>
|
||||
RepeatNone = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The whole queue is repeated.
|
||||
/// </summary>
|
||||
RepeatAll = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The current item is repeated.
|
||||
/// </summary>
|
||||
RepeatOne = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(mimeType);
|
||||
|
||||
var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio;
|
||||
var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && (item is AudioBook || item is not Audio);
|
||||
|
||||
if (type != ImageType.Primary && item is Episode)
|
||||
{
|
||||
|
||||
@@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager
|
||||
return providers;
|
||||
}
|
||||
|
||||
protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options)
|
||||
protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, MetadataRefreshOptions options)
|
||||
{
|
||||
// Get providers to refresh
|
||||
var providers = allImageProviders.Where(i => i is not ILocalImageProvider);
|
||||
|
||||
// When identifying, run the provider the user picked first so the correct image is used.
|
||||
if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
|
||||
{
|
||||
providers = providers
|
||||
.OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
|
||||
}
|
||||
|
||||
var dateLastImageRefresh = item.DateLastRefreshed;
|
||||
|
||||
// Run all if either of these flags are true
|
||||
|
||||
@@ -256,11 +256,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
|
||||
series.Overview = seriesResult.Overview;
|
||||
|
||||
var studios = Enumerable.Empty<string>();
|
||||
|
||||
if (seriesResult.Networks is not null)
|
||||
{
|
||||
series.Studios = seriesResult.Networks.Select(i => i.Name).ToArray();
|
||||
studios = studios.Concat(seriesResult.Networks.Select(i => i.Name).OfType<string>());
|
||||
}
|
||||
|
||||
if (seriesResult.ProductionCompanies is not null)
|
||||
{
|
||||
studios = studios.Concat(seriesResult.ProductionCompanies.Select(i => i.Name).OfType<string>());
|
||||
}
|
||||
|
||||
series.SetStudios(studios);
|
||||
|
||||
if (seriesResult.Genres is not null)
|
||||
{
|
||||
series.Genres = seriesResult.Genres.Select(i => i.Name).ToArray();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -48,6 +49,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
PersonKind.Producer
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Writing jobs to keep.
|
||||
/// </summary>
|
||||
private static readonly FrozenSet<string> _writerJobs = new[]
|
||||
{
|
||||
"writer",
|
||||
"screenplay",
|
||||
"novel"
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[GeneratedRegex(@"[\W_-[·]]+")]
|
||||
private static partial Regex NonWordRegex();
|
||||
|
||||
@@ -82,7 +93,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
}
|
||||
|
||||
if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(crew.Job, "writer", StringComparison.OrdinalIgnoreCase) || string.Equals(crew.Job, "screenplay", StringComparison.OrdinalIgnoreCase)))
|
||||
&& crew.Job is not null && _writerJobs.Contains(crew.Job))
|
||||
{
|
||||
return PersonKind.Writer;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.MediaEncoding.Keyframes.FfProbe;
|
||||
|
||||
@@ -31,6 +32,7 @@ public static class FfProbeKeyframeExtractor
|
||||
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
RedirectStandardOutput = true,
|
||||
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Jellyfin.Api.Controllers;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Api.Tests.Controllers;
|
||||
|
||||
// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio
|
||||
// and video segment endpoints are not authenticated. These tests pin down that requests escaping
|
||||
// the transcode directory are rejected while legitimate ones still serve a file.
|
||||
public sealed class HlsSegmentControllerTests
|
||||
{
|
||||
private readonly Mock<IFileSystem> _fileSystem = new();
|
||||
private readonly Mock<IServerConfigurationManager> _config = new();
|
||||
private readonly Mock<ITranscodeManager> _transcodeManager = new();
|
||||
private readonly string _transcodePath;
|
||||
|
||||
public HlsSegmentControllerTests()
|
||||
{
|
||||
_transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests");
|
||||
Directory.CreateDirectory(_transcodePath);
|
||||
|
||||
_config.Setup(c => c.GetConfiguration("encoding"))
|
||||
.Returns(new EncodingOptions { TranscodingTempPath = _transcodePath });
|
||||
_config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of<IApplicationPaths>());
|
||||
}
|
||||
|
||||
private HlsSegmentController CreateController(string requestPath)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Path = requestPath;
|
||||
|
||||
return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = httpContext }
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
|
||||
{
|
||||
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
|
||||
|
||||
var result = controller.GetHlsAudioSegmentLegacy("abc", "segment");
|
||||
|
||||
Assert.IsType<PhysicalFileResult>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../../../etc/passwd")]
|
||||
[InlineData("subdir/../../../../etc/passwd")]
|
||||
public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
|
||||
{
|
||||
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
|
||||
|
||||
var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest()
|
||||
{
|
||||
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
|
||||
|
||||
// A rooted segment id makes Path.GetFullPath discard the transcode base.
|
||||
var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd";
|
||||
var result = controller.GetHlsAudioSegmentLegacy("abc", rooted);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest()
|
||||
{
|
||||
var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
|
||||
|
||||
// Resolves to "<transcodePath>-evil/passwd", which shares the transcode path as a string prefix.
|
||||
var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd");
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile()
|
||||
{
|
||||
var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
|
||||
|
||||
var result = controller.GetHlsPlaylistLegacy("abc", "list");
|
||||
|
||||
Assert.IsType<PhysicalFileResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest()
|
||||
{
|
||||
// Playlist endpoint serves only .m3u8, even for a path inside the transcode dir.
|
||||
var controller = CreateController("/Videos/abc/hls/list/stream.mp4");
|
||||
|
||||
var result = controller.GetHlsPlaylistLegacy("abc", "list");
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../../../etc/passwd")]
|
||||
public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId)
|
||||
{
|
||||
var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
|
||||
|
||||
var result = controller.GetHlsPlaylistLegacy("abc", playlistId);
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
|
||||
{
|
||||
_fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
|
||||
.Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") });
|
||||
|
||||
var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
|
||||
|
||||
var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
|
||||
|
||||
Assert.IsType<PhysicalFileResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound()
|
||||
{
|
||||
_fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
|
||||
.Returns(Array.Empty<string>());
|
||||
|
||||
var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
|
||||
|
||||
var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../../../etc/passwd")]
|
||||
public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
|
||||
{
|
||||
var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
|
||||
|
||||
var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts");
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
_fileSystem.Verify(f => f.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
}
|
||||
129
tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
Normal file
129
tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Jellyfin.Api.Controllers;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Common.Updates;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Api.Tests.Controllers;
|
||||
|
||||
// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath
|
||||
// must resolve to a file inside the plugin's own directory.
|
||||
public sealed class PluginsControllerTests
|
||||
{
|
||||
private readonly Mock<IPluginManager> _pluginManager = new();
|
||||
private readonly string _pluginPath;
|
||||
|
||||
public PluginsControllerTests()
|
||||
{
|
||||
_pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests");
|
||||
Directory.CreateDirectory(_pluginPath);
|
||||
}
|
||||
|
||||
private PluginsController CreateController() =>
|
||||
new PluginsController(Mock.Of<IInstallationManager>(), _pluginManager.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
private void SetupPlugin(Guid id, Version version, string? imagePath)
|
||||
{
|
||||
var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath };
|
||||
_pluginManager.Setup(p => p.GetPlugin(id, version))
|
||||
.Returns(new LocalPlugin(_pluginPath, true, manifest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPluginImage_UnknownPlugin_ReturnsNotFound()
|
||||
{
|
||||
var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0));
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPluginImage_ImageInsidePluginPath_ReturnsFile()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var version = new Version(1, 0);
|
||||
File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty<byte>());
|
||||
SetupPlugin(id, version, "logo.png");
|
||||
|
||||
var result = CreateController().GetPluginImage(id, version);
|
||||
|
||||
Assert.IsType<PhysicalFileResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var version = new Version(1, 0);
|
||||
SetupPlugin(id, version, "does-not-exist.png");
|
||||
|
||||
var result = CreateController().GetPluginImage(id, version);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../../../etc/passwd")]
|
||||
[InlineData("subdir/../../../../etc/passwd")]
|
||||
public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var version = new Version(1, 0);
|
||||
SetupPlugin(id, version, imagePath);
|
||||
|
||||
var result = CreateController().GetPluginImage(id, version);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var version = new Version(1, 0);
|
||||
// Resolves to "<pluginPath>-evil/logo.png", which shares the plugin path as a string prefix.
|
||||
// The file is created so the check fails on the boundary, not on File.Exists.
|
||||
var siblingDir = _pluginPath + "-evil";
|
||||
Directory.CreateDirectory(siblingDir);
|
||||
File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty<byte>());
|
||||
SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png");
|
||||
|
||||
var result = CreateController().GetPluginImage(id, version);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var version = new Version(1, 0);
|
||||
SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd");
|
||||
|
||||
var result = CreateController().GetPluginImage(id, version);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var version = new Version(1, 0);
|
||||
SetupPlugin(id, version, imagePath);
|
||||
|
||||
var result = CreateController().GetPluginImage(id, version);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ namespace Jellyfin.MediaEncoding.Tests
|
||||
[InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)]
|
||||
[InlineData(EncoderValidatorTestsData.FFmpegV432Output, false)]
|
||||
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, true)]
|
||||
[InlineData(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, true)]
|
||||
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)]
|
||||
public void ValidateVersionInternalTest(string versionOutput, bool valid)
|
||||
{
|
||||
@@ -41,6 +42,7 @@ namespace Jellyfin.MediaEncoding.Tests
|
||||
Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4));
|
||||
Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2));
|
||||
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 4));
|
||||
Add(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, new Version(4, 4));
|
||||
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,15 @@ libswscale 5. 9.100 / 5. 9.100
|
||||
libswresample 3. 9.100 / 3. 9.100
|
||||
libpostproc 55. 9.100 / 55. 9.100";
|
||||
|
||||
public const string FFmpegGitWithoutLibpostprocOutput = @"ffmpeg version N-122128-gdeadbeef Copyright (c) 2000-2026 the FFmpeg developers
|
||||
libavutil 60. 26.102 / 60. 26.102
|
||||
libavcodec 62. 28.102 / 62. 28.102
|
||||
libavformat 62. 12.102 / 62. 12.102
|
||||
libavdevice 62. 3.102 / 62. 3.102
|
||||
libavfilter 11. 14.102 / 11. 14.102
|
||||
libswscale 9. 5.102 / 9. 5.102
|
||||
libswresample 6. 3.102 / 6. 3.102";
|
||||
|
||||
public const string FFmpegGitUnknownOutput = @"ffmpeg version N-45325-gb173e0353-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2018 the FFmpeg developers
|
||||
built with gcc 6.3.0 (Debian 6.3.0-18+deb9u1) 20170516
|
||||
configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc-6 --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gray --enable-libfribidi --enable-libass --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libzimg
|
||||
|
||||
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
@@ -56,57 +57,63 @@ public class DtoServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries()
|
||||
public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage()
|
||||
{
|
||||
var (episode, season, series) = BuildEpisode(seasonHasPoster: true);
|
||||
var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
|
||||
var (episode, season, _) = BuildEpisode(seasonHasPoster: true);
|
||||
var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] };
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(episode, options);
|
||||
|
||||
// The episode's own 16:9 primary is dropped in favor of the season's portrait poster.
|
||||
Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.Null(dto.SeriesPrimaryImageTag);
|
||||
// The season poster is attached additively; the episode keeps its own primary and 16:9 ratio,
|
||||
// and clients decide per view whether to prefer the parent/series poster over the episode still.
|
||||
Assert.NotNull(dto.ImageTags);
|
||||
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.NotNull(dto.SeriesPrimaryImageTag);
|
||||
Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
|
||||
Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
|
||||
// Aspect ratio follows the (portrait) poster, not the episode's 16:9 image.
|
||||
Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
|
||||
// Aspect ratio stays the episode's own image, not the poster's.
|
||||
Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
|
||||
public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster()
|
||||
{
|
||||
var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
|
||||
var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
|
||||
var options = new DtoOptions(false);
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(episode, options);
|
||||
|
||||
Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.Null(dto.SeriesPrimaryImageTag);
|
||||
// Episode image is retained; ParentPrimaryImage falls back to the series poster.
|
||||
Assert.NotNull(dto.ImageTags);
|
||||
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.NotNull(dto.SeriesPrimaryImageTag);
|
||||
Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
|
||||
Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
|
||||
public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary()
|
||||
{
|
||||
var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
|
||||
var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false);
|
||||
var options = new DtoOptions(false);
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(episode, options);
|
||||
|
||||
// Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
|
||||
// With no season or series poster there is nothing to attach; the episode keeps its own primary.
|
||||
Assert.NotNull(dto.ImageTags);
|
||||
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.NotNull(dto.SeriesPrimaryImageTag);
|
||||
Assert.Null(dto.ParentPrimaryImageItemId);
|
||||
}
|
||||
|
||||
private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
|
||||
private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true)
|
||||
{
|
||||
// Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
|
||||
// item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
|
||||
var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
|
||||
series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
|
||||
if (seriesHasPoster)
|
||||
{
|
||||
series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
|
||||
}
|
||||
|
||||
var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
|
||||
if (seasonHasPoster)
|
||||
|
||||
@@ -150,7 +150,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStaticMediaSources_PrimaryQueried_PopulatesPerVersionPositionsAndDefaultsToMostRecent()
|
||||
public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
|
||||
{
|
||||
var (primary, alt1, alt2) = SetupVersionGroup();
|
||||
SetupUserDataBatch(new Dictionary<Guid, UserItemData>
|
||||
@@ -161,12 +161,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
|
||||
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
|
||||
|
||||
// Each version carries its own resume point; the primary has none.
|
||||
Assert.Equal((long?)10, sources.First(s => s.Id == alt1.Id.ToString("N")).PlaybackPositionTicks);
|
||||
Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
|
||||
Assert.Null(sources.First(s => s.Id == primary.Id.ToString("N")).PlaybackPositionTicks);
|
||||
|
||||
// The most recently played version is the default source, so resuming plays the right file.
|
||||
// Per-user positions live in each version's UserData, not on the source.
|
||||
Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
|
||||
}
|
||||
|
||||
@@ -182,9 +178,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
|
||||
|
||||
// An explicitly opened version keeps its own source first, even when a sibling was
|
||||
// played more recently, but the sibling's resume point is still populated.
|
||||
// played more recently.
|
||||
Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
|
||||
Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
|
||||
Assert.Equal(3, sources.Count);
|
||||
}
|
||||
|
||||
@@ -197,7 +192,6 @@ namespace Jellyfin.Server.Implementations.Tests.Library
|
||||
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
|
||||
|
||||
Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
|
||||
Assert.All(sources, s => Assert.Null(s.PlaybackPositionTicks));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user