mirror of https://github.com/jellyfin/jellyfin.git
Integrate branch 'master' into feature/language_filters
This commit is contained in:
commit
677aa508ea
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "Development Jellyfin Server - FFmpeg",
|
||||
"image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy",
|
||||
// restores nuget packages, installs the dotnet workloads and installs the dev https certificate
|
||||
"postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust; sudo bash \"./.devcontainer/Dev - Server Ffmpeg/install-ffmpeg.sh\"",
|
||||
// reads the extensions list and installs them
|
||||
"postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/dotnet:2": {
|
||||
"version": "none",
|
||||
"dotnetRuntimeVersions": "8.0",
|
||||
"aspNetCoreRuntimeVersions": "8.0"
|
||||
},
|
||||
"ghcr.io/devcontainers-contrib/features/apt-packages:1": {
|
||||
"preserve_apt_list": false,
|
||||
"packages": ["libfontconfig1"]
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"dockerDashComposeVersion": "v2"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {}
|
||||
},
|
||||
"hostRequirements": {
|
||||
"memory": "8gb",
|
||||
"cpus": 4
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
|
||||
## configure the following for a manuall install of a specific version from the repo
|
||||
|
||||
# wget https://repo.jellyfin.org/releases/server/ubuntu/versions/jellyfin-ffmpeg/6.0.1-1/jellyfin-ffmpeg6_6.0.1-1-jammy_amd64.deb -O ffmpeg.deb
|
||||
|
||||
# sudo apt update
|
||||
# sudo apt install -f ./ffmpeg.deb -y
|
||||
# rm ffmpeg.deb
|
||||
|
||||
|
||||
## Add the jellyfin repo
|
||||
sudo apt install curl gnupg -y
|
||||
sudo apt-get install software-properties-common -y
|
||||
sudo add-apt-repository universe -y
|
||||
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg
|
||||
export VERSION_OS="$( awk -F'=' '/^ID=/{ print $NF }' /etc/os-release )"
|
||||
export VERSION_CODENAME="$( awk -F'=' '/^VERSION_CODENAME=/{ print $NF }' /etc/os-release )"
|
||||
export DPKG_ARCHITECTURE="$( dpkg --print-architecture )"
|
||||
cat <<EOF | sudo tee /etc/apt/sources.list.d/jellyfin.sources
|
||||
Types: deb
|
||||
URIs: https://repo.jellyfin.org/${VERSION_OS}
|
||||
Suites: ${VERSION_CODENAME}
|
||||
Components: main
|
||||
Architectures: ${DPKG_ARCHITECTURE}
|
||||
Signed-By: /etc/apt/keyrings/jellyfin.gpg
|
||||
EOF
|
||||
|
||||
sudo apt update -y
|
||||
sudo apt install jellyfin-ffmpeg6 -y
|
|
@ -27,11 +27,11 @@ jobs:
|
|||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1
|
||||
uses: github/codeql-action/init@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1
|
||||
uses: github/codeql-action/autobuild@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0b21cf2492b6b02c465a3e5d7c473717ad7721ba # v3.23.1
|
||||
uses: github/codeql-action/analyze@e8893c57a1f3a2b659b6b55564fdfdbbd2982911 # v3.24.0
|
||||
|
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0
|
||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
||||
with:
|
||||
name: openapi-head
|
||||
retention-days: 14
|
||||
|
@ -59,7 +59,7 @@ jobs:
|
|||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@694cdabd8bdb0f10b2cea11669e1bf5453eed0a6 # v4.2.0
|
||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 14
|
||||
|
@ -78,12 +78,12 @@ jobs:
|
|||
- openapi-base
|
||||
steps:
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
||||
uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Download openapi-base
|
||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
||||
uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
|
@ -105,14 +105,14 @@ jobs:
|
|||
body="${body//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$body
|
||||
- name: Find difference comment
|
||||
uses: peter-evans/find-comment@a54c31d7fa095754bfef525c0c8e5e5674c4b4b1 # v2.4.0
|
||||
uses: peter-evans/find-comment@d5fe37641ad8451bdd80312415672ba26c86575e # v3.0.0
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
direction: last
|
||||
body-includes: openapi-diff-workflow-comment
|
||||
- name: Reply or edit difference comment (changed)
|
||||
uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ steps.read-diff.outputs.body != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
@ -127,7 +127,7 @@ jobs:
|
|||
|
||||
</details>
|
||||
- name: Edit difference comment (unchanged)
|
||||
uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@4d510cbed8a05af5aefea46c7fd6e05b95844c89 # 5.2.0
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@68f1963d9876d2ac78bfd1c41c395514b7318855 # 5.2.1
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify as seen
|
||||
uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
|
@ -43,7 +43,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify as seen
|
||||
uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ github.event.comment != null }}
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
@ -58,7 +58,7 @@ jobs:
|
|||
|
||||
- name: Notify as running
|
||||
id: comment_running
|
||||
uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ github.event.comment != null }}
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
@ -93,7 +93,7 @@ jobs:
|
|||
exit ${retcode}
|
||||
|
||||
- name: Notify with result success
|
||||
uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ github.event.comment != null && success() }}
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
@ -108,7 +108,7 @@ jobs:
|
|||
reactions: hooray
|
||||
|
||||
- name: Notify with result failure
|
||||
uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3.1.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ github.event.comment != null && failure() }}
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"recommendations": [
|
||||
"ms-dotnettools.csharp",
|
||||
"editorconfig.editorconfig",
|
||||
"GitHub.vscode-github-actions",
|
||||
"github.vscode-github-actions",
|
||||
"ms-dotnettools.vscode-dotnet-runtime",
|
||||
"ms-dotnettools.csdevkit"
|
||||
],
|
||||
|
|
|
@ -29,6 +29,18 @@
|
|||
"stopAtEntry": false,
|
||||
"internalConsoleOptions": "openOnSessionStart"
|
||||
},
|
||||
{
|
||||
"name": "ghcs .NET Launch (nowebclient, ffmpeg)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net8.0/jellyfin.dll",
|
||||
"args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"],
|
||||
"cwd": "${workspaceFolder}/Jellyfin.Server",
|
||||
"console": "internalConsole",
|
||||
"stopAtEntry": false,
|
||||
"internalConsoleOptions": "openOnSessionStart"
|
||||
},
|
||||
{
|
||||
"name": ".NET Attach",
|
||||
"type": "coreclr",
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
- [97carmine](https://github.com/97carmine)
|
||||
- [Abbe98](https://github.com/Abbe98)
|
||||
- [agrenott](https://github.com/agrenott)
|
||||
- [alltilla](https://github.com/alltilla)
|
||||
- [AndreCarvalho](https://github.com/AndreCarvalho)
|
||||
- [anthonylavado](https://github.com/anthonylavado)
|
||||
- [Artiume](https://github.com/Artiume)
|
||||
|
@ -77,6 +78,7 @@
|
|||
- [Marenz](https://github.com/Marenz)
|
||||
- [marius-luca-87](https://github.com/marius-luca-87)
|
||||
- [mark-monteiro](https://github.com/mark-monteiro)
|
||||
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
||||
- [Matt07211](https://github.com/Matt07211)
|
||||
- [Maxr1998](https://github.com/Maxr1998)
|
||||
- [mcarlton00](https://github.com/mcarlton00)
|
||||
|
@ -176,6 +178,7 @@
|
|||
- [Pithaya](https://github.com/Pithaya)
|
||||
- [Çağrı Sakaoğlu](https://github.com/ilovepilav)
|
||||
- [TheMelmacian](https://github.com/TheMelmacian)
|
||||
_ [Barasingha](https://github.com/MaVdbussche)
|
||||
- [Gauvino](https://github.com/Gauvino)
|
||||
|
||||
# Emby Contributors
|
||||
|
|
|
@ -4,18 +4,19 @@
|
|||
</PropertyGroup>
|
||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||
<ItemGroup Label="Package Dependencies">
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="6.3.4" />
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
<PackageVersion Include="BDInfo" Version="0.7.6.2" />
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.3.0" />
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.3.0" />
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.3.2" />
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.3.2" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageVersion Include="Diacritics" Version="3.3.27" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.1.2" />
|
||||
<PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.2.0" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.1" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.4" />
|
||||
|
@ -47,7 +48,7 @@
|
|||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.4.0" />
|
||||
<PackageVersion Include="Mono.Nat" Version="3.0.4" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
|
@ -71,7 +72,7 @@
|
|||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.7" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="1.0.0.10" />
|
||||
<PackageVersion Include="Svg.Skia" Version="1.0.0.13" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
|
|
|
@ -62,7 +62,6 @@ using MediaBrowser.Controller.MediaEncoding;
|
|||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.QuickConnect;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
|
@ -393,7 +392,7 @@ namespace Emby.Server.Implementations
|
|||
/// Runs the startup tasks.
|
||||
/// </summary>
|
||||
/// <returns><see cref="Task" />.</returns>
|
||||
public async Task RunStartupTasksAsync()
|
||||
public Task RunStartupTasksAsync()
|
||||
{
|
||||
Logger.LogInformation("Running startup tasks");
|
||||
|
||||
|
@ -405,38 +404,10 @@ namespace Emby.Server.Implementations
|
|||
Resolve<IMediaEncoder>().SetFFmpegPath();
|
||||
|
||||
Logger.LogInformation("ServerId: {ServerId}", SystemId);
|
||||
|
||||
var entryPoints = GetExports<IServerEntryPoint>();
|
||||
|
||||
var stopWatch = new Stopwatch();
|
||||
stopWatch.Start();
|
||||
|
||||
await Task.WhenAll(StartEntryPoints(entryPoints, true)).ConfigureAwait(false);
|
||||
Logger.LogInformation("Executed all pre-startup entry points in {Elapsed:g}", stopWatch.Elapsed);
|
||||
|
||||
Logger.LogInformation("Core startup complete");
|
||||
CoreStartupHasCompleted = true;
|
||||
|
||||
stopWatch.Restart();
|
||||
|
||||
await Task.WhenAll(StartEntryPoints(entryPoints, false)).ConfigureAwait(false);
|
||||
Logger.LogInformation("Executed all post-startup entry points in {Elapsed:g}", stopWatch.Elapsed);
|
||||
stopWatch.Stop();
|
||||
}
|
||||
|
||||
private IEnumerable<Task> StartEntryPoints(IEnumerable<IServerEntryPoint> entryPoints, bool isBeforeStartup)
|
||||
{
|
||||
foreach (var entryPoint in entryPoints)
|
||||
{
|
||||
if (isBeforeStartup != (entryPoint is IRunBeforeStartup))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.LogDebug("Starting entry point {Type}", entryPoint.GetType());
|
||||
|
||||
yield return entryPoint.RunAsync();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
@ -695,8 +666,6 @@ namespace Emby.Server.Implementations
|
|||
GetExports<IMetadataSaver>(),
|
||||
GetExports<IExternalId>());
|
||||
|
||||
Resolve<ILiveTvManager>().AddParts(GetExports<ILiveTvService>(), GetExports<IListingsProvider>());
|
||||
|
||||
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
||||
}
|
||||
|
||||
|
|
|
@ -13,19 +13,19 @@ using MediaBrowser.Controller.Configuration;
|
|||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.EntryPoints;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="IServerEntryPoint"/> that notifies users when libraries are updated.
|
||||
/// A <see cref="IHostedService"/> responsible for notifying users when libraries are updated.
|
||||
/// </summary>
|
||||
public sealed class LibraryChangedNotifier : IServerEntryPoint
|
||||
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
|
@ -70,7 +70,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RunAsync()
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded += OnLibraryItemAdded;
|
||||
_libraryManager.ItemUpdated += OnLibraryItemUpdated;
|
||||
|
@ -83,6 +83,20 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraryManager.ItemAdded -= OnLibraryItemAdded;
|
||||
_libraryManager.ItemUpdated -= OnLibraryItemUpdated;
|
||||
_libraryManager.ItemRemoved -= OnLibraryItemRemoved;
|
||||
|
||||
_providerManager.RefreshCompleted -= OnProviderRefreshCompleted;
|
||||
_providerManager.RefreshStarted -= OnProviderRefreshStarted;
|
||||
_providerManager.RefreshProgress -= OnProviderRefreshProgress;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnProviderRefreshProgress(object? sender, GenericEventArgs<Tuple<BaseItem, double>> e)
|
||||
{
|
||||
var item = e.Argument.Item1;
|
||||
|
@ -137,9 +151,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint
|
|||
}
|
||||
|
||||
private void OnProviderRefreshStarted(object? sender, GenericEventArgs<BaseItem> e)
|
||||
{
|
||||
OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0)));
|
||||
}
|
||||
=> OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 0)));
|
||||
|
||||
private void OnProviderRefreshCompleted(object? sender, GenericEventArgs<BaseItem> e)
|
||||
{
|
||||
|
@ -342,7 +354,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint
|
|||
return item.SourceType == SourceType.Library;
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren)
|
||||
private static IEnumerable<string> GetTopParentIds(List<BaseItem> items, List<Folder> allUserRootChildren)
|
||||
{
|
||||
var list = new List<string>();
|
||||
|
||||
|
@ -363,7 +375,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint
|
|||
return list.Distinct(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private IEnumerable<T> TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false)
|
||||
private T[] TranslatePhysicalItemToUserLibrary<T>(T item, User user, bool includeIfNotFound = false)
|
||||
where T : BaseItem
|
||||
{
|
||||
// If the physical root changed, return the user root
|
||||
|
@ -384,18 +396,7 @@ public sealed class LibraryChangedNotifier : IServerEntryPoint
|
|||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_libraryManager.ItemAdded -= OnLibraryItemAdded;
|
||||
_libraryManager.ItemUpdated -= OnLibraryItemUpdated;
|
||||
_libraryManager.ItemRemoved -= OnLibraryItemRemoved;
|
||||
|
||||
_providerManager.RefreshCompleted -= OnProviderRefreshCompleted;
|
||||
_providerManager.RefreshStarted -= OnProviderRefreshStarted;
|
||||
_providerManager.RefreshProgress -= OnProviderRefreshProgress;
|
||||
|
||||
if (_libraryUpdateTimer is not null)
|
||||
{
|
||||
_libraryUpdateTimer.Dispose();
|
||||
_libraryUpdateTimer = null;
|
||||
}
|
||||
_libraryUpdateTimer?.Dispose();
|
||||
_libraryUpdateTimer = null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
@ -8,14 +6,17 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Emby.Server.Implementations.EntryPoints
|
||||
{
|
||||
public sealed class UserDataChangeNotifier : IServerEntryPoint
|
||||
/// <summary>
|
||||
/// <see cref="IHostedService"/> responsible for notifying users when associated item data is updated.
|
||||
/// </summary>
|
||||
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
|
||||
{
|
||||
private const int UpdateDuration = 500;
|
||||
|
||||
|
@ -23,25 +24,43 @@ namespace Emby.Server.Implementations.EntryPoints
|
|||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new Dictionary<Guid, List<BaseItem>>();
|
||||
private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new();
|
||||
private readonly object _syncLock = new();
|
||||
|
||||
private readonly object _syncLock = new object();
|
||||
private Timer? _updateTimer;
|
||||
|
||||
public UserDataChangeNotifier(IUserDataManager userDataManager, ISessionManager sessionManager, IUserManager userManager)
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userDataManager">The <see cref="IUserDataManager"/>.</param>
|
||||
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
|
||||
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
|
||||
public UserDataChangeNotifier(
|
||||
IUserDataManager userDataManager,
|
||||
ISessionManager sessionManager,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_userDataManager = userDataManager;
|
||||
_sessionManager = sessionManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public Task RunAsync()
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved += OnUserDataManagerUserDataSaved;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved -= OnUserDataManagerUserDataSaved;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnUserDataManagerUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||
{
|
||||
if (e.SaveReason == UserDataSaveReason.PlaybackProgress)
|
||||
|
@ -103,55 +122,40 @@ namespace Emby.Server.Implementations.EntryPoints
|
|||
}
|
||||
}
|
||||
|
||||
await SendNotifications(changes, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SendNotifications(List<KeyValuePair<Guid, List<BaseItem>>> changes, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach ((var key, var value) in changes)
|
||||
foreach (var (userId, changedItems) in changes)
|
||||
{
|
||||
await SendNotifications(key, value, cancellationToken).ConfigureAwait(false);
|
||||
await _sessionManager.SendMessageToUserSessions(
|
||||
[userId],
|
||||
SessionMessageType.UserDataChanged,
|
||||
() => GetUserDataChangeInfo(userId, changedItems),
|
||||
default).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task SendNotifications(Guid userId, List<BaseItem> changedItems, CancellationToken cancellationToken)
|
||||
{
|
||||
return _sessionManager.SendMessageToUserSessions(new List<Guid> { userId }, SessionMessageType.UserDataChanged, () => GetUserDataChangeInfo(userId, changedItems), cancellationToken);
|
||||
}
|
||||
|
||||
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
|
||||
var dtoList = changedItems
|
||||
.DistinctBy(x => x.Id)
|
||||
.Select(i =>
|
||||
{
|
||||
var dto = _userDataManager.GetUserDataDto(i, user);
|
||||
dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
return dto;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var userIdString = userId.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
return new UserDataChangeInfo
|
||||
{
|
||||
UserId = userIdString,
|
||||
|
||||
UserDataList = dtoList
|
||||
UserId = userId.ToString("N", CultureInfo.InvariantCulture),
|
||||
UserDataList = changedItems
|
||||
.DistinctBy(x => x.Id)
|
||||
.Select(i =>
|
||||
{
|
||||
var dto = _userDataManager.GetUserDataDto(i, user);
|
||||
dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
return dto;
|
||||
})
|
||||
.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_updateTimer is not null)
|
||||
{
|
||||
_updateTimer.Dispose();
|
||||
_updateTimer = null;
|
||||
}
|
||||
|
||||
_userDataManager.UserDataSaved -= OnUserDataManagerUserDataSaved;
|
||||
_updateTimer?.Dispose();
|
||||
_updateTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
|
@ -11,11 +9,13 @@ using MediaBrowser.Controller.Configuration;
|
|||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.IO
|
||||
{
|
||||
public class LibraryMonitor : ILibraryMonitor
|
||||
/// <inheritdoc cref="ILibraryMonitor" />
|
||||
public sealed class LibraryMonitor : ILibraryMonitor, IDisposable
|
||||
{
|
||||
private readonly ILogger<LibraryMonitor> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
@ -25,19 +25,19 @@ namespace Emby.Server.Implementations.IO
|
|||
/// <summary>
|
||||
/// The file system watchers.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, FileSystemWatcher> _fileSystemWatchers = new ConcurrentDictionary<string, FileSystemWatcher>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, FileSystemWatcher> _fileSystemWatchers = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The affected paths.
|
||||
/// </summary>
|
||||
private readonly List<FileRefresher> _activeRefreshers = new List<FileRefresher>();
|
||||
private readonly List<FileRefresher> _activeRefreshers = [];
|
||||
|
||||
/// <summary>
|
||||
/// A dynamic list of paths that should be ignored. Added to during our own file system modifications.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _disposed = false;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LibraryMonitor" /> class.
|
||||
|
@ -46,34 +46,31 @@ namespace Emby.Server.Implementations.IO
|
|||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="configurationManager">The configuration manager.</param>
|
||||
/// <param name="fileSystem">The filesystem.</param>
|
||||
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
|
||||
public LibraryMonitor(
|
||||
ILogger<LibraryMonitor> logger,
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager configurationManager,
|
||||
IFileSystem fileSystem)
|
||||
IFileSystem fileSystem,
|
||||
IHostApplicationLifetime appLifetime)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_configurationManager = configurationManager;
|
||||
_fileSystem = fileSystem;
|
||||
|
||||
appLifetime.ApplicationStarted.Register(Start);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add the path to our temporary ignore list. Use when writing to a path within our listening scope.
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
private void TemporarilyIgnore(string path)
|
||||
{
|
||||
_tempIgnoredPaths[path] = path;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportFileSystemChangeBeginning(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
TemporarilyIgnore(path);
|
||||
_tempIgnoredPaths[path] = path;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async void ReportFileSystemChangeComplete(string path, bool refreshPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
@ -107,14 +104,10 @@ namespace Emby.Server.Implementations.IO
|
|||
|
||||
var options = _libraryManager.GetLibraryOptions(item);
|
||||
|
||||
if (options is not null)
|
||||
{
|
||||
return options.EnableRealtimeMonitor;
|
||||
}
|
||||
|
||||
return false;
|
||||
return options is not null && options.EnableRealtimeMonitor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Start()
|
||||
{
|
||||
_libraryManager.ItemAdded += OnLibraryManagerItemAdded;
|
||||
|
@ -306,20 +299,11 @@ namespace Emby.Server.Implementations.IO
|
|||
{
|
||||
if (removeFromList)
|
||||
{
|
||||
RemoveWatcherFromList(watcher);
|
||||
_fileSystemWatchers.TryRemove(watcher.Path, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the watcher from list.
|
||||
/// </summary>
|
||||
/// <param name="watcher">The watcher.</param>
|
||||
private void RemoveWatcherFromList(FileSystemWatcher watcher)
|
||||
{
|
||||
_fileSystemWatchers.TryRemove(watcher.Path, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Error event of the watcher control.
|
||||
/// </summary>
|
||||
|
@ -352,6 +336,7 @@ namespace Emby.Server.Implementations.IO
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportFileSystemChanged(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
@ -479,31 +464,15 @@ namespace Emby.Server.Implementations.IO
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases unmanaged and - optionally - managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
Stop();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
|
||||
namespace Emby.Server.Implementations.IO
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="IServerEntryPoint" /> which is responsible for starting the library monitor.
|
||||
/// </summary>
|
||||
public sealed class LibraryMonitorStartup : IServerEntryPoint
|
||||
{
|
||||
private readonly ILibraryMonitor _monitor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LibraryMonitorStartup"/> class.
|
||||
/// </summary>
|
||||
/// <param name="monitor">The library monitor.</param>
|
||||
public LibraryMonitorStartup(ILibraryMonitor monitor)
|
||||
{
|
||||
_monitor = monitor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RunAsync()
|
||||
{
|
||||
_monitor.Start();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,7 +22,6 @@ using Jellyfin.Data.Entities;
|
|||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
|
@ -1022,7 +1021,7 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
// Start by just validating the children of the root, but go no further
|
||||
await RootFolder.ValidateChildren(
|
||||
new SimpleProgress<double>(),
|
||||
new Progress<double>(),
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem)),
|
||||
recursive: false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
@ -1030,7 +1029,7 @@ namespace Emby.Server.Implementations.Library
|
|||
await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await GetUserRootFolder().ValidateChildren(
|
||||
new SimpleProgress<double>(),
|
||||
new Progress<double>(),
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem)),
|
||||
recursive: false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
@ -1048,18 +1047,14 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
await ValidateTopLibraryFolders(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var innerProgress = new ActionableProgress<double>();
|
||||
|
||||
innerProgress.RegisterAction(pct => progress.Report(pct * 0.96));
|
||||
var innerProgress = new Progress<double>(pct => progress.Report(pct * 0.96));
|
||||
|
||||
// Validate the entire media library
|
||||
await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress.Report(96);
|
||||
|
||||
innerProgress = new ActionableProgress<double>();
|
||||
|
||||
innerProgress.RegisterAction(pct => progress.Report(96 + (pct * .04)));
|
||||
innerProgress = new Progress<double>(pct => progress.Report(96 + (pct * .04)));
|
||||
|
||||
await RunPostScanTasks(innerProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
@ -1081,12 +1076,10 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
var innerProgress = new ActionableProgress<double>();
|
||||
|
||||
// Prevent access to modified closure
|
||||
var currentNumComplete = numComplete;
|
||||
|
||||
innerProgress.RegisterAction(pct =>
|
||||
var innerProgress = new Progress<double>(pct =>
|
||||
{
|
||||
double innerPercent = pct;
|
||||
innerPercent /= 100;
|
||||
|
@ -2954,7 +2947,7 @@ namespace Emby.Server.Implementations.Library
|
|||
Task.Run(() =>
|
||||
{
|
||||
// No need to start if scanning the library because it will handle it
|
||||
ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None);
|
||||
ValidateMediaLibrary(new Progress<double>(), CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -48,20 +48,23 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
if (!string.IsNullOrEmpty(cacheKey))
|
||||
{
|
||||
FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath);
|
||||
try
|
||||
{
|
||||
mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath);
|
||||
|
||||
// _logger.LogDebug("Found cached media info");
|
||||
await using (jsonStream.ConfigureAwait(false))
|
||||
{
|
||||
mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
// _logger.LogDebug("Found cached media info");
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Could not open cached media info");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deserializing mediainfo cache");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await jsonStream.DisposeAsync().ConfigureAwait(false);
|
||||
_logger.LogError(ex, "Error opening cached media info");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ using System.Linq;
|
|||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
|
@ -52,7 +53,7 @@ namespace Emby.Server.Implementations.Library
|
|||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
private readonly ConcurrentDictionary<string, ILiveStream> _openStreams = new ConcurrentDictionary<string, ILiveStream>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1);
|
||||
private readonly AsyncNonKeyedLocker _liveStreamLocker = new(1);
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
private IMediaSourceProvider[] _providers;
|
||||
|
@ -468,12 +469,10 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MediaSourceInfo mediaSource;
|
||||
ILiveStream liveStream;
|
||||
|
||||
try
|
||||
using (await _liveStreamLocker.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var (provider, keyId) = GetProvider(request.OpenToken);
|
||||
|
||||
|
@ -493,10 +492,6 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
_openStreams[mediaSource.LiveStreamId] = liveStream;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveStreamSemaphore.Release();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -837,9 +832,7 @@ namespace Emby.Server.Implementations.Library
|
|||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(id);
|
||||
|
||||
await _liveStreamSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _liveStreamLocker.LockAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (_openStreams.TryGetValue(id, out ILiveStream liveStream))
|
||||
{
|
||||
|
@ -858,10 +851,6 @@ namespace Emby.Server.Implementations.Library
|
|||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_liveStreamSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private (IMediaSourceProvider MediaSourceProvider, string KeyId) GetProvider(string key)
|
||||
|
@ -898,7 +887,7 @@ namespace Emby.Server.Implementations.Library
|
|||
CloseLiveStream(key).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
_liveStreamSemaphore.Dispose();
|
||||
_liveStreamLocker.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
"PluginUninstalledWithName": "{0} eemaldati",
|
||||
"PluginInstalledWithName": "{0} paigaldati",
|
||||
"Plugin": "Plugin",
|
||||
"Playlists": "Pleilistid",
|
||||
"Playlists": "Esitusloendid",
|
||||
"Photos": "Fotod",
|
||||
"NotificationOptionVideoPlaybackStopped": "Video taasesitus lõppes",
|
||||
"NotificationOptionVideoPlayback": "Video taasesitus algas",
|
||||
|
@ -123,5 +123,7 @@
|
|||
"External": "Väline",
|
||||
"HearingImpaired": "Kuulmispuudega",
|
||||
"TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.",
|
||||
"TaskKeyframeExtractor": "Võtmekaadri ekstraktor"
|
||||
"TaskKeyframeExtractor": "Võtmekaadri ekstraktor",
|
||||
"TaskRefreshTrickplayImages": "Loo eelvaate pildid",
|
||||
"TaskRefreshTrickplayImagesDescription": "Loob eelvaated videotele, kus lubatud."
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -124,5 +124,7 @@
|
|||
"External": "Luaran",
|
||||
"TaskOptimizeDatabase": "Optimumkan pangkalan data",
|
||||
"TaskKeyframeExtractor": "Ekstrak bingkai kunci",
|
||||
"TaskKeyframeExtractorDescription": "Ekstrak bingkai kunci dari fail video untuk membina HLS playlist yang lebih tepat. Tugas ini mungkin perlukan masa yang panjang."
|
||||
"TaskKeyframeExtractorDescription": "Ekstrak bingkai kunci dari fail video untuk membina HLS playlist yang lebih tepat. Tugas ini mungkin perlukan masa yang panjang.",
|
||||
"TaskRefreshTrickplayImagesDescription": "Jana gambar prebiu Trickplay untuk video dalam perpustakaan.",
|
||||
"TaskRefreshTrickplayImages": "Jana gambar Trickplay"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"External": "ବହିଃସ୍ଥ",
|
||||
"Genres": "ଧରଣ"
|
||||
"Genres": "ଧରଣ",
|
||||
"Albums": "ଆଲବମଗୁଡ଼ିକ",
|
||||
"Artists": "କଳାକାରଗୁଡ଼ିକ",
|
||||
"Application": "ଆପ୍ଲିକେସନ",
|
||||
"Books": "ବହିଗୁଡ଼ିକ",
|
||||
"Channels": "ଚ୍ୟାନେଲଗୁଡ଼ିକ",
|
||||
"ChapterNameValue": "ବିଭାଗ {0}",
|
||||
"Collections": "ସଂଗ୍ରହଗୁଡ଼ିକ",
|
||||
"Folders": "ଫୋଲ୍ଡରଗୁଡ଼ିକ"
|
||||
}
|
||||
|
|
|
@ -124,5 +124,7 @@
|
|||
"TaskKeyframeExtractor": "Ekstraktor ključnih sličic",
|
||||
"External": "Zunanji",
|
||||
"TaskKeyframeExtractorDescription": "Iz video datoteke Izvleče ključne sličice, da ustvari bolj natančne sezname predvajanja HLS. Proces lahko traja dolgo časa.",
|
||||
"HearingImpaired": "Oslabljen sluh"
|
||||
"HearingImpaired": "Oslabljen sluh",
|
||||
"TaskRefreshTrickplayImages": "Ustvari Trickplay slike",
|
||||
"TaskRefreshTrickplayImagesDescription": "Ustvari trickplay predoglede za posnetke v omogočenih knjižnicah."
|
||||
}
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
{}
|
||||
{
|
||||
"Books": "کتابیں"
|
||||
}
|
||||
|
|
|
@ -123,5 +123,7 @@
|
|||
"TaskKeyframeExtractor": "Trích Xuất Khung Hình",
|
||||
"TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.",
|
||||
"External": "Bên ngoài",
|
||||
"HearingImpaired": "Khiếm Thính"
|
||||
"HearingImpaired": "Khiếm Thính",
|
||||
"TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật."
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ using Jellyfin.Data.Events;
|
|||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
@ -371,7 +370,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
|
|||
throw new InvalidOperationException("Cannot execute a Task that is already running");
|
||||
}
|
||||
|
||||
var progress = new SimpleProgress<double>();
|
||||
var progress = new Progress<double>();
|
||||
|
||||
CurrentCancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
|
|
|
@ -294,9 +294,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||
|
||||
if (!System.IO.File.Exists(playlistPath))
|
||||
{
|
||||
var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath);
|
||||
await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!System.IO.File.Exists(playlistPath))
|
||||
{
|
||||
|
@ -326,10 +324,6 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
transcodingLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
|
@ -1442,95 +1436,80 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath);
|
||||
await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var released = false;
|
||||
var startTranscoding = false;
|
||||
|
||||
try
|
||||
using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var startTranscoding = false;
|
||||
if (System.IO.File.Exists(segmentPath))
|
||||
{
|
||||
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
transcodingLock.Release();
|
||||
released = true;
|
||||
_logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
|
||||
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
|
||||
var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
|
||||
|
||||
if (segmentId == -1)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
|
||||
startTranscoding = true;
|
||||
segmentId = 0;
|
||||
}
|
||||
else if (currentTranscodingIndex is null)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
|
||||
startTranscoding = true;
|
||||
}
|
||||
else if (segmentId < currentTranscodingIndex.Value)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex);
|
||||
startTranscoding = true;
|
||||
}
|
||||
else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId);
|
||||
startTranscoding = true;
|
||||
}
|
||||
|
||||
if (startTranscoding)
|
||||
{
|
||||
// If the playlist doesn't already exist, startup ffmpeg
|
||||
try
|
||||
{
|
||||
await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (currentTranscodingIndex.HasValue)
|
||||
{
|
||||
DeleteLastFile(playlistPath, segmentExtension, 0);
|
||||
}
|
||||
|
||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||
|
||||
state.WaitForPath = segmentPath;
|
||||
job = await _transcodeManager.StartFfMpeg(
|
||||
state,
|
||||
playlistPath,
|
||||
GetCommandLineArguments(playlistPath, state, false, segmentId),
|
||||
Request.HttpContext.User.GetUserId(),
|
||||
TranscodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
state.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
// await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
|
||||
var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
|
||||
|
||||
if (segmentId == -1)
|
||||
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
if (job?.TranscodingThrottler is not null)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
|
||||
startTranscoding = true;
|
||||
segmentId = 0;
|
||||
await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
|
||||
}
|
||||
else if (currentTranscodingIndex is null)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
|
||||
startTranscoding = true;
|
||||
}
|
||||
else if (segmentId < currentTranscodingIndex.Value)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex);
|
||||
startTranscoding = true;
|
||||
}
|
||||
else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange)
|
||||
{
|
||||
_logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId);
|
||||
startTranscoding = true;
|
||||
}
|
||||
|
||||
if (startTranscoding)
|
||||
{
|
||||
// If the playlist doesn't already exist, startup ffmpeg
|
||||
try
|
||||
{
|
||||
await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (currentTranscodingIndex.HasValue)
|
||||
{
|
||||
DeleteLastFile(playlistPath, segmentExtension, 0);
|
||||
}
|
||||
|
||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||
|
||||
state.WaitForPath = segmentPath;
|
||||
job = await _transcodeManager.StartFfMpeg(
|
||||
state,
|
||||
playlistPath,
|
||||
GetCommandLineArguments(playlistPath, state, false, segmentId),
|
||||
Request.HttpContext.User.GetUserId(),
|
||||
TranscodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
state.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
// await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
|
||||
if (job?.TranscodingThrottler is not null)
|
||||
{
|
||||
await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!released)
|
||||
{
|
||||
transcodingLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ using System.Linq;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
|
@ -17,7 +16,6 @@ using Jellyfin.Data.Enums;
|
|||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
@ -313,7 +311,7 @@ public class LibraryController : BaseJellyfinApiController
|
|||
{
|
||||
try
|
||||
{
|
||||
await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
@ -6,11 +6,9 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Api.Models.LibraryStructureDto;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
@ -180,7 +178,7 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||
// No need to start if scanning the library because it will handle it
|
||||
if (refreshLibrary)
|
||||
{
|
||||
await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -224,7 +222,7 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||
// No need to start if scanning the library because it will handle it
|
||||
if (refreshLibrary)
|
||||
{
|
||||
await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -293,7 +291,7 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||
// No need to start if scanning the library because it will handle it
|
||||
if (refreshLibrary)
|
||||
{
|
||||
await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
@ -43,6 +43,7 @@ namespace Jellyfin.Api.Controllers;
|
|||
public class LiveTvController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILiveTvManager _liveTvManager;
|
||||
private readonly IGuideManager _guideManager;
|
||||
private readonly ITunerHostManager _tunerHostManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
@ -56,6 +57,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||
/// Initializes a new instance of the <see cref="LiveTvController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param>
|
||||
/// <param name="guideManager">Instance of the <see cref="IGuideManager"/> interface.</param>
|
||||
/// <param name="tunerHostManager">Instance of the <see cref="ITunerHostManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
|
@ -66,6 +68,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
|
||||
public LiveTvController(
|
||||
ILiveTvManager liveTvManager,
|
||||
IGuideManager guideManager,
|
||||
ITunerHostManager tunerHostManager,
|
||||
IUserManager userManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
|
@ -76,6 +79,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||
ITranscodeManager transcodeManager)
|
||||
{
|
||||
_liveTvManager = liveTvManager;
|
||||
_guideManager = guideManager;
|
||||
_tunerHostManager = tunerHostManager;
|
||||
_userManager = userManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
|
@ -941,9 +945,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||
[Authorize(Policy = Policies.LiveTvAccess)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<GuideInfo> GetGuideInfo()
|
||||
{
|
||||
return _liveTvManager.GetGuideInfo();
|
||||
}
|
||||
=> _guideManager.GetGuideInfo();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a tuner host.
|
||||
|
|
|
@ -188,16 +188,24 @@ public class SystemController : BaseJellyfinApiController
|
|||
/// <param name="name">The name of the log file to get.</param>
|
||||
/// <response code="200">Log file retrieved.</response>
|
||||
/// <response code="403">User does not have permission to get log files.</response>
|
||||
/// <response code="404">Could not find a log file with the name.</response>
|
||||
/// <returns>The log file.</returns>
|
||||
[HttpGet("Logs/Log")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesFile(MediaTypeNames.Text.Plain)]
|
||||
public ActionResult GetLogFile([FromQuery, Required] string name)
|
||||
{
|
||||
var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
|
||||
.First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
var file = _fileSystem
|
||||
.GetFiles(_appPaths.LogDirectoryPath)
|
||||
.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (file is null)
|
||||
{
|
||||
return NotFound("Log file not found.");
|
||||
}
|
||||
|
||||
// For older files, assume fully static
|
||||
var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite;
|
||||
|
|
|
@ -325,6 +325,7 @@ public class DynamicHlsHelper
|
|||
if (state.VideoStream is not null && state.VideoStream.VideoRange != VideoRange.Unknown)
|
||||
{
|
||||
var videoRange = state.VideoStream.VideoRange;
|
||||
var videoRangeType = state.VideoStream.VideoRangeType;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
if (videoRange == VideoRange.SDR)
|
||||
|
@ -334,7 +335,14 @@ public class DynamicHlsHelper
|
|||
|
||||
if (videoRange == VideoRange.HDR)
|
||||
{
|
||||
builder.Append(",VIDEO-RANGE=PQ");
|
||||
if (videoRangeType == VideoRangeType.HLG)
|
||||
{
|
||||
builder.Append(",VIDEO-RANGE=HLG");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(",VIDEO-RANGE=PQ");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
|
@ -93,9 +93,7 @@ public static class FileStreamResponseHelpers
|
|||
return new OkResult();
|
||||
}
|
||||
|
||||
var transcodingLock = transcodeManager.GetTranscodingLock(outputPath);
|
||||
await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
try
|
||||
using (await transcodeManager.LockAsync(outputPath, cancellationTokenSource.Token).ConfigureAwait(false))
|
||||
{
|
||||
TranscodingJob? job;
|
||||
if (!File.Exists(outputPath))
|
||||
|
@ -117,9 +115,5 @@ public static class FileStreamResponseHelpers
|
|||
var stream = new ProgressiveFileStream(outputPath, job, transcodeManager);
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
transcodingLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions.Json.Converters;
|
||||
|
@ -50,6 +51,18 @@ public class ClientCapabilitiesDto
|
|||
/// </summary>
|
||||
public string? IconUrl { get; set; }
|
||||
|
||||
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
|
||||
// TODO: Remove after 10.9
|
||||
[Obsolete("Unused")]
|
||||
[DefaultValue(false)]
|
||||
public bool? SupportsContentUploading { get; set; }
|
||||
|
||||
// TODO: Remove after 10.9
|
||||
[Obsolete("Unused")]
|
||||
[DefaultValue(false)]
|
||||
public bool? SupportsSync { get; set; }
|
||||
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
|
||||
|
||||
/// <summary>
|
||||
/// Convert the dto to the full <see cref="ClientCapabilities"/> model.
|
||||
/// </summary>
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
<PackageReference Include="EFCoreSecondLevelCacheInterceptor" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
|
|
|
@ -6,6 +6,7 @@ using System.Linq;
|
|||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Data.Entities;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
|
@ -37,7 +38,7 @@ public class TrickplayManager : ITrickplayManager
|
|||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
||||
private static readonly SemaphoreSlim _resourcePool = new(1, 1);
|
||||
private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
|
||||
private static readonly string[] _trickplayImgExtensions = { ".jpg" };
|
||||
|
||||
/// <summary>
|
||||
|
@ -107,93 +108,92 @@ public class TrickplayManager : ITrickplayManager
|
|||
var imgTempDir = string.Empty;
|
||||
var outputDir = GetTrickplayDirectory(video, width);
|
||||
|
||||
await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width))
|
||||
{
|
||||
_logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract images
|
||||
// Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay.
|
||||
var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id));
|
||||
|
||||
if (mediaSource is null)
|
||||
{
|
||||
_logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaPath = mediaSource.Path;
|
||||
var mediaStream = mediaSource.VideoStream;
|
||||
var container = mediaSource.Container;
|
||||
|
||||
_logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id);
|
||||
imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
|
||||
mediaPath,
|
||||
container,
|
||||
mediaSource,
|
||||
mediaStream,
|
||||
width,
|
||||
TimeSpan.FromMilliseconds(options.Interval),
|
||||
options.EnableHwAcceleration,
|
||||
options.ProcessThreads,
|
||||
options.Qscale,
|
||||
options.ProcessPriority,
|
||||
_encodingHelper,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
|
||||
{
|
||||
throw new InvalidOperationException("Null or invalid directory from media encoder.");
|
||||
}
|
||||
|
||||
var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
|
||||
.Select(i => i.FullName)
|
||||
.OrderBy(i => i)
|
||||
.ToList();
|
||||
|
||||
// Create tiles
|
||||
var trickplayInfo = CreateTiles(images, width, options, outputDir);
|
||||
|
||||
// Save tiles info
|
||||
try
|
||||
{
|
||||
if (trickplayInfo is not null)
|
||||
if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width))
|
||||
{
|
||||
trickplayInfo.ItemId = video.Id;
|
||||
await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
|
||||
_logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id);
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
// Extract images
|
||||
// Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay.
|
||||
var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id));
|
||||
|
||||
if (mediaSource is null)
|
||||
{
|
||||
throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
|
||||
_logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaPath = mediaSource.Path;
|
||||
var mediaStream = mediaSource.VideoStream;
|
||||
var container = mediaSource.Container;
|
||||
|
||||
_logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id);
|
||||
imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
|
||||
mediaPath,
|
||||
container,
|
||||
mediaSource,
|
||||
mediaStream,
|
||||
width,
|
||||
TimeSpan.FromMilliseconds(options.Interval),
|
||||
options.EnableHwAcceleration,
|
||||
options.ProcessThreads,
|
||||
options.Qscale,
|
||||
options.ProcessPriority,
|
||||
_encodingHelper,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
|
||||
{
|
||||
throw new InvalidOperationException("Null or invalid directory from media encoder.");
|
||||
}
|
||||
|
||||
var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
|
||||
.Select(i => i.FullName)
|
||||
.OrderBy(i => i)
|
||||
.ToList();
|
||||
|
||||
// Create tiles
|
||||
var trickplayInfo = CreateTiles(images, width, options, outputDir);
|
||||
|
||||
// Save tiles info
|
||||
try
|
||||
{
|
||||
if (trickplayInfo is not null)
|
||||
{
|
||||
trickplayInfo.ItemId = video.Id;
|
||||
await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error while saving trickplay tiles info.");
|
||||
|
||||
// Make sure no files stay in metadata folders on failure
|
||||
// if tiles info wasn't saved.
|
||||
Directory.Delete(outputDir, true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error while saving trickplay tiles info.");
|
||||
|
||||
// Make sure no files stay in metadata folders on failure
|
||||
// if tiles info wasn't saved.
|
||||
Directory.Delete(outputDir, true);
|
||||
_logger.LogError(ex, "Error creating trickplay images.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating trickplay images.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_resourcePool.Release();
|
||||
|
||||
if (!string.IsNullOrEmpty(imgTempDir))
|
||||
finally
|
||||
{
|
||||
Directory.Delete(imgTempDir, true);
|
||||
if (!string.IsNullOrEmpty(imgTempDir))
|
||||
{
|
||||
Directory.Delete(imgTempDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Data.Queries;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Session;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
public sealed class DeviceAccessEntryPoint : IServerEntryPoint
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
public DeviceAccessEntryPoint(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_deviceManager = deviceManager;
|
||||
_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
public Task RunAsync()
|
||||
{
|
||||
_userManager.OnUserUpdated += OnUserUpdated;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private async void OnUserUpdated(object? sender, GenericEventArgs<User> e)
|
||||
{
|
||||
var user = e.Argument;
|
||||
if (!user.HasPermission(PermissionKind.EnableAllDevices))
|
||||
{
|
||||
await UpdateDeviceAccess(user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateDeviceAccess(User user)
|
||||
{
|
||||
var existing = (await _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
UserId = user.Id
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
foreach (var device in existing)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId))
|
||||
{
|
||||
await _sessionManager.Logout(device).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Data.Queries;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Users;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHostedService"/> responsible for managing user device permissions.
|
||||
/// </summary>
|
||||
public sealed class DeviceAccessHost : IHostedService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeviceAccessHost"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
|
||||
/// <param name="deviceManager">The <see cref="IDeviceManager"/>.</param>
|
||||
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
|
||||
public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_deviceManager = deviceManager;
|
||||
_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userManager.OnUserUpdated += OnUserUpdated;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userManager.OnUserUpdated -= OnUserUpdated;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void OnUserUpdated(object? sender, GenericEventArgs<User> e)
|
||||
{
|
||||
var user = e.Argument;
|
||||
if (!user.HasPermission(PermissionKind.EnableAllDevices))
|
||||
{
|
||||
await UpdateDeviceAccess(user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateDeviceAccess(User user)
|
||||
{
|
||||
var existing = (await _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
UserId = user.Id
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
foreach (var device in existing)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId))
|
||||
{
|
||||
await _sessionManager.Logout(device).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,10 @@ using System.Net.Http;
|
|||
using System.Net.Http.Headers;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using Emby.Server.Implementations.EntryPoints;
|
||||
using Jellyfin.Api.Middleware;
|
||||
using Jellyfin.LiveTv;
|
||||
using Jellyfin.LiveTv.EmbyTV;
|
||||
using Jellyfin.LiveTv.Extensions;
|
||||
using Jellyfin.MediaEncoding.Hls.Extensions;
|
||||
using Jellyfin.Networking;
|
||||
|
@ -17,6 +20,7 @@ using Jellyfin.Server.Infrastructure;
|
|||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Extensions;
|
||||
using MediaBrowser.XbmcMetadata;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -124,7 +128,13 @@ namespace Jellyfin.Server
|
|||
services.AddHlsPlaylistGenerator();
|
||||
services.AddLiveTvServices();
|
||||
|
||||
services.AddHostedService<LiveTvHost>();
|
||||
services.AddHostedService<AutoDiscoveryHost>();
|
||||
services.AddHostedService<PortForwardingHost>();
|
||||
services.AddHostedService<NfoUserDataSaver>();
|
||||
services.AddHostedService<LibraryChangedNotifier>();
|
||||
services.AddHostedService<UserDataChangeNotifier>();
|
||||
services.AddHostedService<RecordingNotifier>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
#pragma warning disable CS1591
|
||||
#pragma warning disable CA1003
|
||||
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Common.Progress
|
||||
{
|
||||
/// <summary>
|
||||
/// Class ActionableProgress.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type for the action parameter.</typeparam>
|
||||
public class ActionableProgress<T> : IProgress<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// The _actions.
|
||||
/// </summary>
|
||||
private Action<T>? _action;
|
||||
|
||||
public event EventHandler<T>? ProgressChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the action.
|
||||
/// </summary>
|
||||
/// <param name="action">The action.</param>
|
||||
public void RegisterAction(Action<T> action)
|
||||
{
|
||||
_action = action;
|
||||
}
|
||||
|
||||
public void Report(T value)
|
||||
{
|
||||
ProgressChanged?.Invoke(this, value);
|
||||
|
||||
_action?.Invoke(value);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
#pragma warning disable CS1591
|
||||
#pragma warning disable CA1003
|
||||
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Common.Progress
|
||||
{
|
||||
public class SimpleProgress<T> : IProgress<T>
|
||||
{
|
||||
public event EventHandler<T>? ProgressChanged;
|
||||
|
||||
public void Report(T value)
|
||||
{
|
||||
ProgressChanged?.Invoke(this, value);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,6 @@ using System.Text.Json.Serialization;
|
|||
using System.Threading;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
|
@ -53,7 +52,7 @@ namespace MediaBrowser.Controller.Channels
|
|||
query.ChannelIds = new Guid[] { Id };
|
||||
|
||||
// Don't blow up here because it could cause parent screens with other content to fail
|
||||
return ChannelManager.GetChannelItemsInternal(query, new SimpleProgress<double>(), CancellationToken.None).GetAwaiter().GetResult();
|
||||
return ChannelManager.GetChannelItemsInternal(query, new Progress<double>(), CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
#pragma warning disable CA1711, CS1591
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using MediaBrowser.Model.Drawing;
|
||||
|
||||
namespace MediaBrowser.Controller.Drawing
|
||||
{
|
||||
public class ImageStream : IDisposable
|
||||
{
|
||||
public ImageStream(Stream stream)
|
||||
{
|
||||
Stream = stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stream.
|
||||
/// </summary>
|
||||
/// <value>The stream.</value>
|
||||
public Stream Stream { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the format.
|
||||
/// </summary>
|
||||
/// <value>The format.</value>
|
||||
public ImageFormat Format { get; set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Stream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,6 @@ using System.Threading.Tasks.Dataflow;
|
|||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Collections;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
|
@ -429,16 +428,22 @@ namespace MediaBrowser.Controller.Entities
|
|||
|
||||
if (recursive)
|
||||
{
|
||||
var innerProgress = new ActionableProgress<double>();
|
||||
|
||||
var folder = this;
|
||||
innerProgress.RegisterAction(innerPercent =>
|
||||
var innerProgress = new Progress<double>(innerPercent =>
|
||||
{
|
||||
var percent = ProgressHelpers.GetProgress(ProgressHelpers.UpdatedChildItems, ProgressHelpers.ScannedSubfolders, innerPercent);
|
||||
|
||||
progress.Report(percent);
|
||||
|
||||
ProviderManager.OnRefreshProgress(folder, percent);
|
||||
// TODO: this is sometimes being called after the refresh has completed.
|
||||
try
|
||||
{
|
||||
ProviderManager.OnRefreshProgress(folder, percent);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
Logger.LogError(e, "Error refreshing folder");
|
||||
}
|
||||
});
|
||||
|
||||
if (validChildrenNeedGeneration)
|
||||
|
@ -461,10 +466,8 @@ namespace MediaBrowser.Controller.Entities
|
|||
|
||||
var container = this as IMetadataContainer;
|
||||
|
||||
var innerProgress = new ActionableProgress<double>();
|
||||
|
||||
var folder = this;
|
||||
innerProgress.RegisterAction(innerPercent =>
|
||||
var innerProgress = new Progress<double>(innerPercent =>
|
||||
{
|
||||
var percent = ProgressHelpers.GetProgress(ProgressHelpers.ScannedSubfolders, ProgressHelpers.RefreshedMetadata, innerPercent);
|
||||
|
||||
|
@ -472,7 +475,15 @@ namespace MediaBrowser.Controller.Entities
|
|||
|
||||
if (recursive)
|
||||
{
|
||||
ProviderManager.OnRefreshProgress(folder, percent);
|
||||
// TODO: this is sometimes being called after the refresh has completed.
|
||||
try
|
||||
{
|
||||
ProviderManager.OnRefreshProgress(folder, percent);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
Logger.LogError(e, "Error refreshing folder");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -572,9 +583,7 @@ namespace MediaBrowser.Controller.Entities
|
|||
var actionBlock = new ActionBlock<int>(
|
||||
async i =>
|
||||
{
|
||||
var innerProgress = new ActionableProgress<double>();
|
||||
|
||||
innerProgress.RegisterAction(innerPercent =>
|
||||
var innerProgress = new Progress<double>(innerPercent =>
|
||||
{
|
||||
// round the percent and only update progress if it changed to prevent excessive UpdateProgress calls
|
||||
var innerPercentRounded = Math.Round(innerPercent);
|
||||
|
@ -916,7 +925,7 @@ namespace MediaBrowser.Controller.Entities
|
|||
query.ChannelIds = new[] { ChannelId };
|
||||
|
||||
// Don't blow up here because it could cause parent screens with other content to fail
|
||||
return ChannelManager.GetChannelItemsInternal(query, new SimpleProgress<double>(), CancellationToken.None).GetAwaiter().GetResult();
|
||||
return ChannelManager.GetChannelItemsInternal(query, new Progress<double>(), CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Controller.Library
|
||||
{
|
||||
public interface ILibraryMonitor : IDisposable
|
||||
/// <summary>
|
||||
/// Service responsible for monitoring library filesystems for changes.
|
||||
/// </summary>
|
||||
public interface ILibraryMonitor
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts this instance.
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Model.LiveTv;
|
||||
|
||||
namespace MediaBrowser.Controller.LiveTv;
|
||||
|
||||
/// <summary>
|
||||
/// Service responsible for managing the Live TV guide.
|
||||
/// </summary>
|
||||
public interface IGuideManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the guide information.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="GuideInfo"/>.</returns>
|
||||
GuideInfo GetGuideInfo();
|
||||
|
||||
/// <summary>
|
||||
/// Refresh the guide.
|
||||
/// </summary>
|
||||
/// <param name="progress">The <see cref="IProgress{T}"/> to use.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to use.</param>
|
||||
/// <returns>Task representing the refresh operation.</returns>
|
||||
Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken);
|
||||
}
|
|
@ -67,13 +67,6 @@ namespace MediaBrowser.Controller.LiveTv
|
|||
/// <returns>Task.</returns>
|
||||
Task CancelSeriesTimer(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the parts.
|
||||
/// </summary>
|
||||
/// <param name="services">The services.</param>
|
||||
/// <param name="listingProviders">The listing providers.</param>
|
||||
void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<IListingsProvider> listingProviders);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timer.
|
||||
/// </summary>
|
||||
|
@ -174,12 +167,6 @@ namespace MediaBrowser.Controller.LiveTv
|
|||
/// <returns>Task.</returns>
|
||||
Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the guide information.
|
||||
/// </summary>
|
||||
/// <returns>GuideInfo.</returns>
|
||||
GuideInfo GetGuideInfo();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recommended programs.
|
||||
/// </summary>
|
||||
|
|
|
@ -87,6 +87,12 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
/// <value>The level.</value>
|
||||
public string Level { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the codec tag.
|
||||
/// </summary>
|
||||
/// <value>The codec tag.</value>
|
||||
public string CodecTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the framerate.
|
||||
/// </summary>
|
||||
|
|
|
@ -30,6 +30,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
private const string VaapiAlias = "va";
|
||||
private const string D3d11vaAlias = "dx11";
|
||||
private const string VideotoolboxAlias = "vt";
|
||||
private const string RkmppAlias = "rk";
|
||||
private const string OpenclAlias = "ocl";
|
||||
private const string CudaAlias = "cu";
|
||||
private const string DrmAlias = "dr";
|
||||
|
@ -161,6 +162,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{ "vaapi", hwEncoder + "_vaapi" },
|
||||
{ "videotoolbox", hwEncoder + "_videotoolbox" },
|
||||
{ "v4l2m2m", hwEncoder + "_v4l2m2m" },
|
||||
{ "rkmpp", hwEncoder + "_rkmpp" },
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(hwType)
|
||||
|
@ -217,6 +219,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
&& _mediaEncoder.SupportsFilter("hwupload_vaapi");
|
||||
}
|
||||
|
||||
private bool IsRkmppFullSupported()
|
||||
{
|
||||
return _mediaEncoder.SupportsHwaccel("rkmpp")
|
||||
&& _mediaEncoder.SupportsFilter("scale_rkrga")
|
||||
&& _mediaEncoder.SupportsFilter("vpp_rkrga")
|
||||
&& _mediaEncoder.SupportsFilter("overlay_rkrga");
|
||||
}
|
||||
|
||||
private bool IsOpenclFullSupported()
|
||||
{
|
||||
return _mediaEncoder.SupportsHwaccel("opencl")
|
||||
|
@ -696,6 +706,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return codec.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string GetRkmppDeviceArgs(string alias)
|
||||
{
|
||||
alias ??= RkmppAlias;
|
||||
|
||||
// device selection in rk is not supported.
|
||||
return " -init_hw_device rkmpp=" + alias;
|
||||
}
|
||||
|
||||
private string GetVideoToolboxDeviceArgs(string alias)
|
||||
{
|
||||
alias ??= VideotoolboxAlias;
|
||||
|
@ -835,30 +853,25 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
public string GetGraphicalSubCanvasSize(EncodingJobInfo state)
|
||||
{
|
||||
// DVBSUB and DVDSUB use the fixed canvas size 720x576
|
||||
// DVBSUB uses the fixed canvas size 720x576
|
||||
if (state.SubtitleStream is not null
|
||||
&& state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode
|
||||
&& !state.SubtitleStream.IsTextSubtitleStream
|
||||
&& !string.Equals(state.SubtitleStream.Codec, "DVBSUB", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(state.SubtitleStream.Codec, "DVDSUB", StringComparison.OrdinalIgnoreCase))
|
||||
&& !string.Equals(state.SubtitleStream.Codec, "DVBSUB", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var inW = state.VideoStream?.Width;
|
||||
var inH = state.VideoStream?.Height;
|
||||
var reqW = state.BaseRequest.Width;
|
||||
var reqH = state.BaseRequest.Height;
|
||||
var reqMaxW = state.BaseRequest.MaxWidth;
|
||||
var reqMaxH = state.BaseRequest.MaxHeight;
|
||||
var subtitleWidth = state.SubtitleStream?.Width;
|
||||
var subtitleHeight = state.SubtitleStream?.Height;
|
||||
|
||||
// setup a relative small canvas_size for overlay_qsv/vaapi to reduce transfer overhead
|
||||
var (overlayW, overlayH) = GetFixedOutputSize(inW, inH, reqW, reqH, reqMaxW, 1080);
|
||||
|
||||
if (overlayW.HasValue && overlayH.HasValue)
|
||||
if (subtitleWidth.HasValue
|
||||
&& subtitleHeight.HasValue
|
||||
&& subtitleWidth.Value > 0
|
||||
&& subtitleHeight.Value > 0)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -canvas_size {0}x{1}",
|
||||
overlayW.Value,
|
||||
overlayH.Value);
|
||||
subtitleWidth.Value,
|
||||
subtitleHeight.Value);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1061,6 +1074,33 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
// no videotoolbox hw filter.
|
||||
args.Append(GetVideoToolboxDeviceArgs(VideotoolboxAlias));
|
||||
}
|
||||
else if (string.Equals(optHwaccelType, "rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!isLinux || !_mediaEncoder.SupportsHwaccel("rkmpp"))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var isRkmppDecoder = vidDecoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase);
|
||||
var isRkmppEncoder = vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isRkmppDecoder && !isRkmppEncoder)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
args.Append(GetRkmppDeviceArgs(RkmppAlias));
|
||||
|
||||
var filterDevArgs = string.Empty;
|
||||
var doOclTonemap = isHwTonemapAvailable && IsOpenclFullSupported();
|
||||
|
||||
if (doOclTonemap && !isRkmppDecoder)
|
||||
{
|
||||
args.Append(GetOpenclDeviceArgs(0, null, RkmppAlias, OpenclAlias));
|
||||
filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias);
|
||||
}
|
||||
|
||||
args.Append(filterDevArgs);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(vidDecoder))
|
||||
{
|
||||
|
@ -1477,8 +1517,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "h264_rkmpp", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "hevc_rkmpp", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "av1_qsv", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "av1_nvenc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "av1_amf", StringComparison.OrdinalIgnoreCase)
|
||||
|
@ -1918,20 +1960,22 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
profile = "constrained_baseline";
|
||||
}
|
||||
|
||||
// libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case.
|
||||
// libx264, h264_{qsv,nvenc,rkmpp} does not support Constrained Baseline profile, force Baseline in this case.
|
||||
if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase))
|
||||
|| string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "h264_rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
&& profile.Contains("baseline", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profile = "baseline";
|
||||
}
|
||||
|
||||
// libx264, h264_qsv, h264_nvenc and h264_vaapi does not support Constrained High profile, force High in this case.
|
||||
// libx264, h264_{qsv,nvenc,vaapi,rkmpp} does not support Constrained High profile, force High in this case.
|
||||
if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase))
|
||||
|| string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "h264_rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
&& profile.Contains("high", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profile = "high";
|
||||
|
@ -2015,6 +2059,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
param += " -level " + level;
|
||||
}
|
||||
}
|
||||
else if (string.Equals(videoEncoder, "h264_rkmpp", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "hevc_rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
param += " -level " + level;
|
||||
}
|
||||
else if (!string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
param += " -level " + level;
|
||||
|
@ -2833,6 +2882,48 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return (outputWidth, outputHeight);
|
||||
}
|
||||
|
||||
public static bool IsScaleRatioSupported(
|
||||
int? videoWidth,
|
||||
int? videoHeight,
|
||||
int? requestedWidth,
|
||||
int? requestedHeight,
|
||||
int? requestedMaxWidth,
|
||||
int? requestedMaxHeight,
|
||||
double? maxScaleRatio)
|
||||
{
|
||||
var (outWidth, outHeight) = GetFixedOutputSize(
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
requestedWidth,
|
||||
requestedHeight,
|
||||
requestedMaxWidth,
|
||||
requestedMaxHeight);
|
||||
|
||||
if (!videoWidth.HasValue
|
||||
|| !videoHeight.HasValue
|
||||
|| !outWidth.HasValue
|
||||
|| !outHeight.HasValue
|
||||
|| !maxScaleRatio.HasValue
|
||||
|| (maxScaleRatio.Value < 1.0f))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var minScaleRatio = 1.0f / maxScaleRatio;
|
||||
var scaleRatioW = (double)outWidth / (double)videoWidth;
|
||||
var scaleRatioH = (double)outHeight / (double)videoHeight;
|
||||
|
||||
if (scaleRatioW < minScaleRatio
|
||||
|| scaleRatioW > maxScaleRatio
|
||||
|| scaleRatioH < minScaleRatio
|
||||
|| scaleRatioH > maxScaleRatio)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string GetHwScaleFilter(
|
||||
string hwScaleSuffix,
|
||||
string videoFormat,
|
||||
|
@ -2877,7 +2968,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return string.Empty;
|
||||
}
|
||||
|
||||
public static string GetCustomSwScaleFilter(
|
||||
public static string GetGraphicalSubPreProcessFilters(
|
||||
int? videoWidth,
|
||||
int? videoHeight,
|
||||
int? requestedWidth,
|
||||
|
@ -2897,7 +2988,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"scale=s={0}x{1}:flags=fast_bilinear",
|
||||
@"scale=-1:{1}:fast_bilinear,crop,pad=max({0}\,iw):max({1}\,ih):(ow-iw)/2:(oh-ih)/2:black@0,crop={0}:{1}",
|
||||
outWidth.Value,
|
||||
outHeight.Value);
|
||||
}
|
||||
|
@ -2913,7 +3004,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
int? requestedHeight,
|
||||
int? requestedMaxWidth,
|
||||
int? requestedMaxHeight,
|
||||
int? framerate)
|
||||
float? framerate)
|
||||
{
|
||||
var reqTicks = state.BaseRequest.StartTimeTicks ?? 0;
|
||||
var startTime = TimeSpan.FromTicks(reqTicks).ToString(@"hh\\\:mm\\\:ss\\\.fff", CultureInfo.InvariantCulture);
|
||||
|
@ -2932,7 +3023,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
"alphasrc=s={0}x{1}:r={2}:start='{3}'",
|
||||
outWidth.Value,
|
||||
outHeight.Value,
|
||||
framerate ?? 10,
|
||||
framerate ?? 25,
|
||||
reqTicks > 0 ? startTime : 0);
|
||||
}
|
||||
|
||||
|
@ -3340,9 +3431,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
}
|
||||
else if (hasGraphicalSubs)
|
||||
{
|
||||
// [0:s]scale=s=1280x720
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
}
|
||||
|
||||
|
@ -3504,15 +3594,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
// scale=s=1280x720,format=yuva420p,hwupload
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
subFilters.Add("format=yuva420p");
|
||||
}
|
||||
else if (hasTextSubs)
|
||||
{
|
||||
var framerate = state.VideoStream?.RealFrameRate;
|
||||
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
|
||||
|
||||
// alphasrc=s=1280x720:r=10:start=0,format=yuva420p,subtitles,hwupload
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5);
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate);
|
||||
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
|
||||
subFilters.Add(alphaSrcFilter);
|
||||
subFilters.Add("format=yuva420p");
|
||||
|
@ -3527,8 +3619,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
}
|
||||
}
|
||||
|
@ -3702,15 +3794,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
// scale=s=1280x720,format=yuva420p,hwupload
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
subFilters.Add("format=yuva420p");
|
||||
}
|
||||
else if (hasTextSubs)
|
||||
{
|
||||
var framerate = state.VideoStream?.RealFrameRate;
|
||||
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
|
||||
|
||||
// alphasrc=s=1280x720:r=10:start=0,format=yuva420p,subtitles,hwupload
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5);
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate);
|
||||
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
|
||||
subFilters.Add(alphaSrcFilter);
|
||||
subFilters.Add("format=yuva420p");
|
||||
|
@ -3727,8 +3821,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
}
|
||||
}
|
||||
|
@ -3938,16 +4032,18 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
// scale,format=bgra,hwupload
|
||||
// overlay_qsv can handle overlay scaling,
|
||||
// add a dummy scale filter to pair with -canvas_size.
|
||||
subFilters.Add("scale=flags=fast_bilinear");
|
||||
// overlay_qsv can handle overlay scaling, setup a smaller height to reduce transfer overhead
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, 1080);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
subFilters.Add("format=bgra");
|
||||
}
|
||||
else if (hasTextSubs)
|
||||
{
|
||||
var framerate = state.VideoStream?.RealFrameRate;
|
||||
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
|
||||
|
||||
// alphasrc=s=1280x720:r=10:start=0,format=bgra,subtitles,hwupload
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5);
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, subFramerate);
|
||||
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
|
||||
subFilters.Add(alphaSrcFilter);
|
||||
subFilters.Add("format=bgra");
|
||||
|
@ -3973,8 +4069,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
}
|
||||
}
|
||||
|
@ -4158,12 +4254,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
subFilters.Add("scale=flags=fast_bilinear");
|
||||
// overlay_qsv can handle overlay scaling, setup a smaller height to reduce transfer overhead
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, 1080);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
subFilters.Add("format=bgra");
|
||||
}
|
||||
else if (hasTextSubs)
|
||||
{
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5);
|
||||
var framerate = state.VideoStream?.RealFrameRate;
|
||||
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
|
||||
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, subFramerate);
|
||||
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
|
||||
subFilters.Add(alphaSrcFilter);
|
||||
subFilters.Add("format=bgra");
|
||||
|
@ -4189,8 +4290,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
}
|
||||
}
|
||||
|
@ -4425,12 +4526,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
subFilters.Add("scale=flags=fast_bilinear");
|
||||
// overlay_vaapi can handle overlay scaling, setup a smaller height to reduce transfer overhead
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, 1080);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
subFilters.Add("format=bgra");
|
||||
}
|
||||
else if (hasTextSubs)
|
||||
{
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, hasAssSubs ? 10 : 5);
|
||||
var framerate = state.VideoStream?.RealFrameRate;
|
||||
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
|
||||
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, 1080, subFramerate);
|
||||
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
|
||||
subFilters.Add(alphaSrcFilter);
|
||||
subFilters.Add("format=bgra");
|
||||
|
@ -4454,8 +4560,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
|
||||
if (isVaapiEncoder)
|
||||
|
@ -4599,14 +4705,16 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
// scale=s=1280x720,format=bgra,hwupload
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
subFilters.Add("format=bgra");
|
||||
}
|
||||
else if (hasTextSubs)
|
||||
{
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5);
|
||||
var framerate = state.VideoStream?.RealFrameRate;
|
||||
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
|
||||
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate);
|
||||
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
|
||||
subFilters.Add(alphaSrcFilter);
|
||||
subFilters.Add("format=bgra");
|
||||
|
@ -4815,8 +4923,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subSwScaleFilter);
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
|
||||
if (isVaapiEncoder)
|
||||
|
@ -4898,6 +5006,237 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return (newfilters, swFilterChain.SubFilters, swFilterChain.OverlayFilters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameter of Rockchip RKMPP/RKRGA filter chain.
|
||||
/// </summary>
|
||||
/// <param name="state">Encoding state.</param>
|
||||
/// <param name="options">Encoding options.</param>
|
||||
/// <param name="vidEncoder">Video encoder to use.</param>
|
||||
/// <returns>The tuple contains three lists: main, sub and overlay filters.</returns>
|
||||
public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetRkmppVidFilterChain(
|
||||
EncodingJobInfo state,
|
||||
EncodingOptions options,
|
||||
string vidEncoder)
|
||||
{
|
||||
if (!string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
var isLinux = OperatingSystem.IsLinux();
|
||||
var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty;
|
||||
var isSwDecoder = string.IsNullOrEmpty(vidDecoder);
|
||||
var isSwEncoder = !vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase);
|
||||
var isRkmppOclSupported = isLinux && IsRkmppFullSupported() && IsOpenclFullSupported();
|
||||
|
||||
if ((isSwDecoder && isSwEncoder)
|
||||
|| !isRkmppOclSupported
|
||||
|| !_mediaEncoder.SupportsFilter("alphasrc"))
|
||||
{
|
||||
return GetSwVidFilterChain(state, options, vidEncoder);
|
||||
}
|
||||
|
||||
// prefered rkmpp + rkrga + opencl filters pipeline
|
||||
if (isRkmppOclSupported)
|
||||
{
|
||||
return GetRkmppVidFiltersPrefered(state, options, vidDecoder, vidEncoder);
|
||||
}
|
||||
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetRkmppVidFiltersPrefered(
|
||||
EncodingJobInfo state,
|
||||
EncodingOptions options,
|
||||
string vidDecoder,
|
||||
string vidEncoder)
|
||||
{
|
||||
var inW = state.VideoStream?.Width;
|
||||
var inH = state.VideoStream?.Height;
|
||||
var reqW = state.BaseRequest.Width;
|
||||
var reqH = state.BaseRequest.Height;
|
||||
var reqMaxW = state.BaseRequest.MaxWidth;
|
||||
var reqMaxH = state.BaseRequest.MaxHeight;
|
||||
var threeDFormat = state.MediaSource.Video3DFormat;
|
||||
|
||||
var isRkmppDecoder = vidDecoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase);
|
||||
var isRkmppEncoder = vidEncoder.Contains("rkmpp", StringComparison.OrdinalIgnoreCase);
|
||||
var isSwDecoder = !isRkmppDecoder;
|
||||
var isSwEncoder = !isRkmppEncoder;
|
||||
var isDrmInDrmOut = isRkmppDecoder && isRkmppEncoder;
|
||||
|
||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
||||
var doOclTonemap = IsHwTonemapAvailable(state, options);
|
||||
|
||||
var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
|
||||
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
|
||||
var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream;
|
||||
var hasAssSubs = hasSubs
|
||||
&& (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/* Make main filters for video stream */
|
||||
var mainFilters = new List<string>();
|
||||
|
||||
mainFilters.Add(GetOverwriteColorPropertiesParam(state, doOclTonemap));
|
||||
|
||||
if (isSwDecoder)
|
||||
{
|
||||
// INPUT sw surface(memory)
|
||||
// sw deint
|
||||
if (doDeintH2645)
|
||||
{
|
||||
var swDeintFilter = GetSwDeinterlaceFilter(state, options);
|
||||
mainFilters.Add(swDeintFilter);
|
||||
}
|
||||
|
||||
var outFormat = doOclTonemap ? "yuv420p10le" : (hasGraphicalSubs ? "yuv420p" : "nv12");
|
||||
var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
|
||||
if (!string.IsNullOrEmpty(swScaleFilter))
|
||||
{
|
||||
swScaleFilter += ":flags=fast_bilinear";
|
||||
}
|
||||
|
||||
// sw scale
|
||||
mainFilters.Add(swScaleFilter);
|
||||
mainFilters.Add("format=" + outFormat);
|
||||
|
||||
// keep video at memory except ocl tonemap,
|
||||
// since the overhead caused by hwupload >>> using sw filter.
|
||||
// sw => hw
|
||||
if (doOclTonemap)
|
||||
{
|
||||
mainFilters.Add("hwupload=derive_device=opencl");
|
||||
}
|
||||
}
|
||||
else if (isRkmppDecoder)
|
||||
{
|
||||
// INPUT rkmpp/drm surface(gem/dma-heap)
|
||||
|
||||
var isFullAfbcPipeline = isDrmInDrmOut && !doOclTonemap;
|
||||
var outFormat = doOclTonemap ? "p010" : "nv12";
|
||||
var hwScaleFilter = GetHwScaleFilter("rkrga", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
var hwScaleFilter2 = GetHwScaleFilter("rkrga", string.Empty, inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
|
||||
if (!hasSubs
|
||||
|| !isFullAfbcPipeline
|
||||
|| !string.IsNullOrEmpty(hwScaleFilter2))
|
||||
{
|
||||
// try enabling AFBC to save DDR bandwidth
|
||||
if (!string.IsNullOrEmpty(hwScaleFilter) && isFullAfbcPipeline)
|
||||
{
|
||||
hwScaleFilter += ":afbc=1";
|
||||
}
|
||||
|
||||
// hw scale
|
||||
mainFilters.Add(hwScaleFilter);
|
||||
}
|
||||
}
|
||||
|
||||
if (doOclTonemap && isRkmppDecoder)
|
||||
{
|
||||
// map from rkmpp/drm to opencl via drm-opencl interop.
|
||||
mainFilters.Add("hwmap=derive_device=opencl:mode=read");
|
||||
}
|
||||
|
||||
// ocl tonemap
|
||||
if (doOclTonemap)
|
||||
{
|
||||
var tonemapFilter = GetHwTonemapFilter(options, "opencl", "nv12");
|
||||
// enable tradeoffs for performance
|
||||
if (!string.IsNullOrEmpty(tonemapFilter))
|
||||
{
|
||||
tonemapFilter += ":tradeoff=1";
|
||||
}
|
||||
|
||||
mainFilters.Add(tonemapFilter);
|
||||
}
|
||||
|
||||
var memoryOutput = false;
|
||||
var isUploadForOclTonemap = isSwDecoder && doOclTonemap;
|
||||
if ((isRkmppDecoder && isSwEncoder) || isUploadForOclTonemap)
|
||||
{
|
||||
memoryOutput = true;
|
||||
|
||||
// OUTPUT nv12 surface(memory)
|
||||
mainFilters.Add("hwdownload");
|
||||
mainFilters.Add("format=nv12");
|
||||
}
|
||||
|
||||
// OUTPUT nv12 surface(memory)
|
||||
if (isSwDecoder && isRkmppEncoder)
|
||||
{
|
||||
memoryOutput = true;
|
||||
}
|
||||
|
||||
if (memoryOutput)
|
||||
{
|
||||
// text subtitles
|
||||
if (hasTextSubs)
|
||||
{
|
||||
var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false);
|
||||
mainFilters.Add(textSubtitlesFilter);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDrmInDrmOut)
|
||||
{
|
||||
if (doOclTonemap)
|
||||
{
|
||||
// OUTPUT drm(nv12) surface(gem/dma-heap)
|
||||
// reverse-mapping via drm-opencl interop.
|
||||
mainFilters.Add("hwmap=derive_device=rkmpp:mode=write:reverse=1");
|
||||
mainFilters.Add("format=drm_prime");
|
||||
}
|
||||
}
|
||||
|
||||
/* Make sub and overlay filters for subtitle stream */
|
||||
var subFilters = new List<string>();
|
||||
var overlayFilters = new List<string>();
|
||||
if (isDrmInDrmOut)
|
||||
{
|
||||
if (hasSubs)
|
||||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
subFilters.Add("format=bgra");
|
||||
}
|
||||
else if (hasTextSubs)
|
||||
{
|
||||
var framerate = state.VideoStream?.RealFrameRate;
|
||||
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
|
||||
|
||||
// alphasrc=s=1280x720:r=10:start=0,format=bgra,subtitles,hwupload
|
||||
var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, subFramerate);
|
||||
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
|
||||
subFilters.Add(alphaSrcFilter);
|
||||
subFilters.Add("format=bgra");
|
||||
subFilters.Add(subTextSubtitlesFilter);
|
||||
}
|
||||
|
||||
subFilters.Add("hwupload=derive_device=rkmpp");
|
||||
|
||||
// try enabling AFBC to save DDR bandwidth
|
||||
overlayFilters.Add("overlay_rkrga=eof_action=pass:repeatlast=0:format=nv12:afbc=1");
|
||||
}
|
||||
}
|
||||
else if (memoryOutput)
|
||||
{
|
||||
if (hasGraphicalSubs)
|
||||
{
|
||||
var subPreProcFilters = GetGraphicalSubPreProcessFilters(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
subFilters.Add(subPreProcFilters);
|
||||
overlayFilters.Add("overlay=eof_action=pass:repeatlast=0");
|
||||
}
|
||||
}
|
||||
|
||||
return (mainFilters, subFilters, overlayFilters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameter of video processing filters.
|
||||
/// </summary>
|
||||
|
@ -4944,6 +5283,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
(mainFilters, subFilters, overlayFilters) = GetAppleVidFilterChain(state, options, outputVideoCodec);
|
||||
}
|
||||
else if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
(mainFilters, subFilters, overlayFilters) = GetRkmppVidFilterChain(state, options, outputVideoCodec);
|
||||
}
|
||||
else
|
||||
{
|
||||
(mainFilters, subFilters, overlayFilters) = GetSwVidFilterChain(state, options, outputVideoCodec);
|
||||
|
@ -5075,18 +5418,21 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
if (string.Equals(videoStream.PixelFormat, "yuv420p", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.PixelFormat, "yuvj420p", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.PixelFormat, "yuv422p", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.PixelFormat, "yuv444p", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.PixelFormat, "yuv420p10le", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.PixelFormat, "yuv422p10le", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.PixelFormat, "yuv444p10le", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.PixelFormat, "yuv420p12le", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.PixelFormat, "yuv422p12le", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.PixelFormat, "yuv444p12le", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 12;
|
||||
|
@ -5139,7 +5485,12 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|| string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return null;
|
||||
// One exception is that RKMPP decoder can handle H.264 High 10.
|
||||
if (!(string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase))
|
||||
|
@ -5166,6 +5517,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
return GetVideotoolboxVidDecoder(state, options, videoStream, bitDepth);
|
||||
}
|
||||
|
||||
if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetRkmppVidDecoder(state, options, videoStream, bitDepth);
|
||||
}
|
||||
}
|
||||
|
||||
var whichCodec = videoStream.Codec;
|
||||
|
@ -5231,6 +5587,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(decoderSuffix, "rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return isCodecAvailable ? (" -c:v " + decoderName) : null;
|
||||
}
|
||||
|
||||
|
@ -5253,6 +5614,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
var isCudaSupported = (isLinux || isWindows) && IsCudaFullSupported();
|
||||
var isQsvSupported = (isLinux || isWindows) && _mediaEncoder.SupportsHwaccel("qsv");
|
||||
var isVideotoolboxSupported = isMacOS && _mediaEncoder.SupportsHwaccel("videotoolbox");
|
||||
var isRkmppSupported = isLinux && IsRkmppFullSupported();
|
||||
var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var ffmpegVersion = _mediaEncoder.EncoderVersion;
|
||||
|
@ -5355,6 +5717,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return " -hwaccel videotoolbox" + (outputHwSurface ? " -hwaccel_output_format videotoolbox_vld" : string.Empty);
|
||||
}
|
||||
|
||||
// Rockchip rkmpp
|
||||
if (string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)
|
||||
&& isRkmppSupported
|
||||
&& isCodecAvailable)
|
||||
{
|
||||
return " -hwaccel rkmpp" + (outputHwSurface ? " -hwaccel_output_format drm_prime" : string.Empty);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -5661,6 +6031,102 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return null;
|
||||
}
|
||||
|
||||
public string GetRkmppVidDecoder(EncodingJobInfo state, EncodingOptions options, MediaStream videoStream, int bitDepth)
|
||||
{
|
||||
var isLinux = OperatingSystem.IsLinux();
|
||||
|
||||
if (!isLinux
|
||||
|| !string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var inW = state.VideoStream?.Width;
|
||||
var inH = state.VideoStream?.Height;
|
||||
var reqW = state.BaseRequest.Width;
|
||||
var reqH = state.BaseRequest.Height;
|
||||
var reqMaxW = state.BaseRequest.MaxWidth;
|
||||
var reqMaxH = state.BaseRequest.MaxHeight;
|
||||
|
||||
// rkrga RGA2e supports range from 1/16 to 16
|
||||
if (!IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 16.0f))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var isRkmppOclSupported = IsRkmppFullSupported() && IsOpenclFullSupported();
|
||||
var hwSurface = isRkmppOclSupported
|
||||
&& _mediaEncoder.SupportsFilter("alphasrc");
|
||||
|
||||
// rkrga RGA3 supports range from 1/8 to 8
|
||||
var isAfbcSupported = hwSurface && IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f);
|
||||
|
||||
// TODO: add more 8/10bit and 4:2:2 formats for Rkmpp after finishing the ffcheck tool
|
||||
var is8bitSwFormatsRkmpp = string.Equals("yuv420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase);
|
||||
var is10bitSwFormatsRkmpp = string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase);
|
||||
var is8_10bitSwFormatsRkmpp = is8bitSwFormatsRkmpp || is10bitSwFormatsRkmpp;
|
||||
|
||||
// nv15 and nv20 are bit-stream only formats
|
||||
if (is10bitSwFormatsRkmpp && !hwSurface)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is8bitSwFormatsRkmpp)
|
||||
{
|
||||
if (string.Equals(videoStream.Codec, "mpeg1video", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetHwaccelType(state, options, "mpeg1video", bitDepth, hwSurface);
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.Codec, "mpeg2video", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetHwaccelType(state, options, "mpeg2video", bitDepth, hwSurface);
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.Codec, "mpeg4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetHwaccelType(state, options, "mpeg4", bitDepth, hwSurface);
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.Codec, "vp8", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetHwaccelType(state, options, "vp8", bitDepth, hwSurface);
|
||||
}
|
||||
}
|
||||
|
||||
if (is8_10bitSwFormatsRkmpp)
|
||||
{
|
||||
if (string.Equals(videoStream.Codec, "avc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var accelType = GetHwaccelType(state, options, "h264", bitDepth, hwSurface);
|
||||
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var accelType = GetHwaccelType(state, options, "hevc", bitDepth, hwSurface);
|
||||
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var accelType = GetHwaccelType(state, options, "vp9", bitDepth, hwSurface);
|
||||
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
|
||||
}
|
||||
|
||||
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of threads.
|
||||
/// </summary>
|
||||
|
|
|
@ -619,6 +619,26 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
public string[] GetRequestedCodecTags(string codec)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(BaseRequest.CodecTag))
|
||||
{
|
||||
return BaseRequest.CodecTag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(codec))
|
||||
{
|
||||
var codectag = BaseRequest.GetOption(codec, "codectag");
|
||||
|
||||
if (!string.IsNullOrEmpty(codectag))
|
||||
{
|
||||
return codectag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
public string GetRequestedLevel(string codec)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(BaseRequest.Level))
|
||||
|
|
|
@ -96,9 +96,10 @@ public interface ITranscodeManager
|
|||
public void OnTranscodeEndRequest(TranscodingJob job);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transcoding lock.
|
||||
/// Transcoding lock.
|
||||
/// </summary>
|
||||
/// <param name="outputPath">The output path of the transcoded file.</param>
|
||||
/// <returns>A <see cref="SemaphoreSlim"/>.</returns>
|
||||
public SemaphoreSlim GetTranscodingLock(string outputPath);
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>An <see cref="IDisposable"/>.</returns>
|
||||
ValueTask<IDisposable> LockAsync(string outputPath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
public class ImageEncodingOptions
|
||||
{
|
||||
public string InputPath { get; set; }
|
||||
|
||||
public int? Width { get; set; }
|
||||
|
||||
public int? Height { get; set; }
|
||||
|
||||
public int? MaxWidth { get; set; }
|
||||
|
||||
public int? MaxHeight { get; set; }
|
||||
|
||||
public int? Quality { get; set; }
|
||||
|
||||
public string Format { get; set; }
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
/// <summary>
|
||||
/// Class MediaEncoderHelpers.
|
||||
/// </summary>
|
||||
public static class MediaEncoderHelpers
|
||||
{
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
namespace MediaBrowser.Controller.Plugins
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates that a <see cref="IServerEntryPoint"/> should be invoked as a pre-startup task.
|
||||
/// </summary>
|
||||
public interface IRunBeforeStartup
|
||||
{
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.Plugins
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an entry point for a module in the application. This interface is scanned for automatically and
|
||||
/// provides a hook to initialize the module at application start.
|
||||
/// The entry point can additionally be flagged as a pre-startup task by implementing the
|
||||
/// <see cref="IRunBeforeStartup"/> interface.
|
||||
/// </summary>
|
||||
public interface IServerEntryPoint : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the initialization for this module. This method is invoked at application start.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
Task RunAsync();
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
@ -22,7 +23,7 @@ using Microsoft.Extensions.Logging;
|
|||
|
||||
namespace MediaBrowser.MediaEncoding.Attachments
|
||||
{
|
||||
public sealed class AttachmentExtractor : IAttachmentExtractor
|
||||
public sealed class AttachmentExtractor : IAttachmentExtractor, IDisposable
|
||||
{
|
||||
private readonly ILogger<AttachmentExtractor> _logger;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
@ -30,8 +31,11 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks =
|
||||
new ConcurrentDictionary<string, SemaphoreSlim>();
|
||||
private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
|
||||
{
|
||||
o.PoolSize = 20;
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
|
||||
public AttachmentExtractor(
|
||||
ILogger<AttachmentExtractor> logger,
|
||||
|
@ -84,11 +88,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1));
|
||||
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!Directory.Exists(outputPath))
|
||||
{
|
||||
|
@ -99,10 +99,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExtractAllAttachmentsExternal(
|
||||
|
@ -111,11 +107,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1));
|
||||
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(Path.Join(outputPath, id)))
|
||||
{
|
||||
|
@ -131,10 +123,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAllAttachmentsInternal(
|
||||
|
@ -256,11 +244,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = _semaphoreLocks.GetOrAdd(outputPath, key => new SemaphoreSlim(1, 1));
|
||||
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
|
@ -271,10 +255,6 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAttachmentInternal(
|
||||
|
@ -379,5 +359,11 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
var prefix = filename.AsSpan(0, 1);
|
||||
return Path.Join(_appPaths.DataPath, "attachments", prefix, filename);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_semaphoreLocks.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,7 +45,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
"mpeg4_cuvid",
|
||||
"vp8_cuvid",
|
||||
"vp9_cuvid",
|
||||
"av1_cuvid"
|
||||
"av1_cuvid",
|
||||
"h264_rkmpp",
|
||||
"hevc_rkmpp",
|
||||
"mpeg1_rkmpp",
|
||||
"mpeg2_rkmpp",
|
||||
"mpeg4_rkmpp",
|
||||
"vp8_rkmpp",
|
||||
"vp9_rkmpp",
|
||||
"av1_rkmpp"
|
||||
};
|
||||
|
||||
private static readonly string[] _requiredEncoders = new[]
|
||||
|
@ -82,7 +90,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
"av1_vaapi",
|
||||
"h264_v4l2m2m",
|
||||
"h264_videotoolbox",
|
||||
"hevc_videotoolbox"
|
||||
"hevc_videotoolbox",
|
||||
"h264_rkmpp",
|
||||
"hevc_rkmpp"
|
||||
};
|
||||
|
||||
private static readonly string[] _requiredFilters = new[]
|
||||
|
@ -116,9 +126,12 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
"libplacebo",
|
||||
"scale_vulkan",
|
||||
"overlay_vulkan",
|
||||
"hwupload_vaapi",
|
||||
// videotoolbox
|
||||
"yadif_videotoolbox"
|
||||
"yadif_videotoolbox",
|
||||
// rkrga
|
||||
"scale_rkrga",
|
||||
"vpp_rkrga",
|
||||
"overlay_rkrga"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<int, string[]> _filterOptionsDict = new Dictionary<int, string[]>
|
||||
|
|
|
@ -11,6 +11,7 @@ using System.Text.Json;
|
|||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Jellyfin.Extensions.Json.Converters;
|
||||
|
@ -60,7 +61,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
private readonly IServerConfigurationManager _serverConfig;
|
||||
private readonly string _startupOptionFFmpegPath;
|
||||
|
||||
private readonly SemaphoreSlim _thumbnailResourcePool;
|
||||
private readonly AsyncNonKeyedLocker _thumbnailResourcePool;
|
||||
|
||||
private readonly object _runningProcessesLock = new object();
|
||||
private readonly List<ProcessWrapper> _runningProcesses = new List<ProcessWrapper>();
|
||||
|
@ -116,7 +117,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
_jsonSerializerOptions.Converters.Add(new JsonBoolStringConverter());
|
||||
|
||||
var semaphoreCount = 2 * Environment.ProcessorCount;
|
||||
_thumbnailResourcePool = new SemaphoreSlim(semaphoreCount, semaphoreCount);
|
||||
_thumbnailResourcePool = new(semaphoreCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -754,8 +755,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
{
|
||||
bool ranToCompletion;
|
||||
|
||||
await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
StartProcess(processWrapper);
|
||||
|
||||
|
@ -776,10 +776,6 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
ranToCompletion = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_thumbnailResourcePool.Release();
|
||||
}
|
||||
|
||||
var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1;
|
||||
var file = _fileSystem.GetFileInfo(tempExtractPath);
|
||||
|
@ -908,8 +904,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
{
|
||||
bool ranToCompletion = false;
|
||||
|
||||
await _thumbnailResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
using (await _thumbnailResourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
StartProcess(processWrapper);
|
||||
|
||||
|
@ -963,10 +958,6 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
StopProcess(processWrapper, 1000);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_thumbnailResourcePool.Release();
|
||||
}
|
||||
|
||||
var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||
<PropertyGroup>
|
||||
|
@ -22,6 +22,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
<PackageReference Include="BDInfo" />
|
||||
<PackageReference Include="libse" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
|
|
|
@ -742,6 +742,10 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||
stream.LocalizedExternal = _localization.GetLocalizedString("External");
|
||||
stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
|
||||
|
||||
// Graphical subtitle may have width and height info
|
||||
stream.Width = streamInfo.Width;
|
||||
stream.Height = streamInfo.Height;
|
||||
|
||||
if (string.IsNullOrEmpty(stream.Title))
|
||||
{
|
||||
// mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
@ -11,6 +11,7 @@ using System.Net.Http;
|
|||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
|
@ -27,7 +28,7 @@ using UtfUnknown;
|
|||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
public sealed class SubtitleEncoder : ISubtitleEncoder
|
||||
public sealed class SubtitleEncoder : ISubtitleEncoder, IDisposable
|
||||
{
|
||||
private readonly ILogger<SubtitleEncoder> _logger;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
|
@ -40,8 +41,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
/// <summary>
|
||||
/// The _semaphoreLocks.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks =
|
||||
new ConcurrentDictionary<string, SemaphoreSlim>();
|
||||
private readonly AsyncKeyedLocker<string> _semaphoreLocks = new(o =>
|
||||
{
|
||||
o.PoolSize = 20;
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
|
||||
public SubtitleEncoder(
|
||||
ILogger<SubtitleEncoder> logger,
|
||||
|
@ -194,36 +198,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
{
|
||||
if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string outputFormat;
|
||||
string outputCodec;
|
||||
await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(subtitleStream.Codec, "srt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract
|
||||
outputCodec = "copy";
|
||||
outputFormat = subtitleStream.Codec;
|
||||
}
|
||||
else if (string.Equals(subtitleStream.Codec, "subrip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract
|
||||
outputCodec = "copy";
|
||||
outputFormat = "srt";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Extract
|
||||
outputCodec = "srt";
|
||||
outputFormat = "srt";
|
||||
}
|
||||
|
||||
// Extract
|
||||
var outputFormat = GetTextSubtitleFormat(subtitleStream);
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat);
|
||||
|
||||
await ExtractTextSubtitle(mediaSource, subtitleStream, outputCodec, outputPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SubtitleInfo()
|
||||
{
|
||||
Path = outputPath,
|
||||
|
@ -317,16 +296,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
throw new ArgumentException("Unsupported format: " + format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lock.
|
||||
/// </summary>
|
||||
/// <param name="filename">The filename.</param>
|
||||
/// <returns>System.Object.</returns>
|
||||
private SemaphoreSlim GetLock(string filename)
|
||||
{
|
||||
return _semaphoreLocks.GetOrAdd(filename, _ => new SemaphoreSlim(1, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the text subtitle to SRT.
|
||||
/// </summary>
|
||||
|
@ -337,21 +306,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
/// <returns>Task.</returns>
|
||||
private async Task ConvertTextSubtitleToSrt(MediaStream subtitleStream, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = GetLock(outputPath);
|
||||
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -467,6 +428,203 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
|
||||
}
|
||||
|
||||
private string GetTextSubtitleFormat(MediaStream subtitleStream)
|
||||
{
|
||||
if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return subtitleStream.Codec;
|
||||
}
|
||||
else
|
||||
{
|
||||
return "srt";
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCodecCopyable(string codec)
|
||||
{
|
||||
return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all text subtitles.
|
||||
/// </summary>
|
||||
/// <param name="mediaSource">The mediaSource.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var locks = new List<AsyncKeyedLockReleaser<string>>();
|
||||
var extractableStreams = new List<MediaStream>();
|
||||
|
||||
try
|
||||
{
|
||||
var subtitleStreams = mediaSource.MediaStreams
|
||||
.Where(stream => stream.IsTextSubtitleStream && stream.SupportsExternalStream);
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
{
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream));
|
||||
|
||||
var @lock = _semaphoreLocks.GetOrAdd(outputPath);
|
||||
await @lock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
@lock.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
locks.Add(@lock);
|
||||
extractableStreams.Add(subtitleStream);
|
||||
}
|
||||
|
||||
if (extractableStreams.Count > 0)
|
||||
{
|
||||
await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unable to get streams for File:{File}", mediaSource.Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var @lock in locks)
|
||||
{
|
||||
@lock.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAllTextSubtitlesInternal(
|
||||
MediaSourceInfo mediaSource,
|
||||
List<MediaStream> subtitleStreams,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputPath = mediaSource.Path;
|
||||
var outputPaths = new List<string>();
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i {0} -copyts",
|
||||
inputPath);
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
{
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream));
|
||||
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
|
||||
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
|
||||
if (streamIndex == -1)
|
||||
{
|
||||
_logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index);
|
||||
continue;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
|
||||
|
||||
outputPaths.Add(outputPath);
|
||||
args += string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -map 0:{0} -an -vn -c:s {1} \"{2}\"",
|
||||
streamIndex,
|
||||
outputCodec,
|
||||
outputPath);
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
|
||||
using (var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
FileName = _mediaEncoder.EncoderPath,
|
||||
Arguments = args,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
ErrorDialog = false
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
})
|
||||
{
|
||||
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting ffmpeg");
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false);
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill(true);
|
||||
exitCode = -1;
|
||||
}
|
||||
}
|
||||
|
||||
var failed = false;
|
||||
|
||||
if (exitCode == -1)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
foreach (var outputPath in outputPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var outputPath in outputPaths)
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outputPath.EndsWith("ass", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed)
|
||||
{
|
||||
throw new FfmpegException(
|
||||
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the text subtitle.
|
||||
/// </summary>
|
||||
|
@ -484,16 +642,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = GetLock(outputPath);
|
||||
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
|
||||
try
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
|
||||
var args = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
|
||||
|
||||
if (subtitleStream.IsExternal)
|
||||
|
@ -509,10 +663,6 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractTextSubtitleInternal(
|
||||
|
@ -728,6 +878,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_semaphoreLocks.Dispose();
|
||||
}
|
||||
|
||||
#pragma warning disable CA1034 // Nested types should not be visible
|
||||
// Only public for the unit tests
|
||||
public readonly record struct SubtitleInfo
|
||||
|
|
|
@ -4,10 +4,12 @@ using System.Diagnostics;
|
|||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common;
|
||||
|
@ -43,7 +45,11 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
private readonly IAttachmentExtractor _attachmentExtractor;
|
||||
|
||||
private readonly List<TranscodingJob> _activeTranscodingJobs = new();
|
||||
private readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new();
|
||||
private readonly AsyncKeyedLocker<string> _transcodingLocks = new(o =>
|
||||
{
|
||||
o.PoolSize = 20;
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodeManager"/> class.
|
||||
|
@ -224,11 +230,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
lock (_transcodingLocks)
|
||||
{
|
||||
_transcodingLocks.Remove(job.Path!);
|
||||
}
|
||||
|
||||
job.Stop();
|
||||
|
||||
if (delete(job.Path!))
|
||||
|
@ -625,11 +626,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
lock (_transcodingLocks)
|
||||
{
|
||||
_transcodingLocks.Remove(path);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(state.Request.DeviceId))
|
||||
{
|
||||
_sessionManager.ClearTranscodingInfo(state.Request.DeviceId);
|
||||
|
@ -705,21 +701,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SemaphoreSlim GetTranscodingLock(string outputPath)
|
||||
{
|
||||
lock (_transcodingLocks)
|
||||
{
|
||||
if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result))
|
||||
{
|
||||
result = new SemaphoreSlim(1, 1);
|
||||
_transcodingLocks[outputPath] = result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(e.PlaySessionId))
|
||||
|
@ -742,10 +723,23 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transcoding lock.
|
||||
/// </summary>
|
||||
/// <param name="outputPath">The output path of the transcoded file.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>An <see cref="IDisposable"/>.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public ValueTask<IDisposable> LockAsync(string outputPath, CancellationToken cancellationToken)
|
||||
{
|
||||
return _transcodingLocks.LockAsync(outputPath, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
|
||||
_sessionManager.PlaybackStart -= OnPlaybackProgress;
|
||||
_transcodingLocks.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
using System;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Model.ClientLog
|
||||
{
|
||||
/// <summary>
|
||||
/// The client log event.
|
||||
/// </summary>
|
||||
public class ClientLogEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClientLogEvent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The log timestamp.</param>
|
||||
/// <param name="level">The log level.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="clientName">The client name.</param>
|
||||
/// <param name="clientVersion">The client version.</param>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="message">The message.</param>
|
||||
public ClientLogEvent(
|
||||
DateTime timestamp,
|
||||
LogLevel level,
|
||||
Guid? userId,
|
||||
string clientName,
|
||||
string clientVersion,
|
||||
string deviceId,
|
||||
string message)
|
||||
{
|
||||
Timestamp = timestamp;
|
||||
UserId = userId;
|
||||
ClientName = clientName;
|
||||
ClientVersion = clientVersion;
|
||||
DeviceId = deviceId;
|
||||
Message = message;
|
||||
Level = level;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event timestamp.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the log level.
|
||||
/// </summary>
|
||||
public LogLevel Level { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user id.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the client name.
|
||||
/// </summary>
|
||||
public string ClientName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the client version.
|
||||
/// </summary>
|
||||
public string ClientVersion { get; }
|
||||
|
||||
///
|
||||
/// <summary>
|
||||
/// Gets the device id.
|
||||
/// </summary>
|
||||
public string DeviceId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the log message.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
}
|
||||
}
|
|
@ -1944,6 +1944,38 @@ namespace MediaBrowser.Model.Dlna
|
|||
break;
|
||||
}
|
||||
|
||||
case ProfileConditionValue.VideoCodecTag:
|
||||
{
|
||||
if (string.IsNullOrEmpty(qualifier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// change from split by | to comma
|
||||
// strip spaces to avoid having to encode
|
||||
var values = value
|
||||
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (condition.Condition == ProfileConditionType.Equals)
|
||||
{
|
||||
item.SetOption(qualifier, "codectag", string.Join(',', values));
|
||||
}
|
||||
else if (condition.Condition == ProfileConditionType.EqualsAny)
|
||||
{
|
||||
var currentValue = item.GetOption(qualifier, "codectag");
|
||||
if (!string.IsNullOrEmpty(currentValue) && values.Any(v => string.Equals(v, currentValue, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
item.SetOption(qualifier, "codectag", currentValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
item.SetOption(qualifier, "codectag", string.Join(',', values));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case ProfileConditionValue.Height:
|
||||
{
|
||||
if (!enableNonQualifiedConditions)
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
#nullable disable
|
||||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Dto
|
||||
{
|
||||
public class ImageByNameInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name.
|
||||
/// </summary>
|
||||
/// <value>The name.</value>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the theme.
|
||||
/// </summary>
|
||||
/// <value>The theme.</value>
|
||||
public string Theme { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the context.
|
||||
/// </summary>
|
||||
/// <value>The context.</value>
|
||||
public string Context { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the length of the file.
|
||||
/// </summary>
|
||||
/// <value>The length of the file.</value>
|
||||
public long FileLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the format.
|
||||
/// </summary>
|
||||
/// <value>The format.</value>
|
||||
public string Format { get; set; }
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
namespace MediaBrowser.Model.Entities
|
||||
{
|
||||
public static class SpecialFolder
|
||||
{
|
||||
public const string TvShowSeries = "TvShowSeries";
|
||||
public const string TvGenres = "TvGenres";
|
||||
public const string TvGenre = "TvGenre";
|
||||
public const string TvLatest = "TvLatest";
|
||||
public const string TvNextUp = "TvNextUp";
|
||||
public const string TvResume = "TvResume";
|
||||
public const string TvFavoriteSeries = "TvFavoriteSeries";
|
||||
public const string TvFavoriteEpisodes = "TvFavoriteEpisodes";
|
||||
|
||||
public const string MovieLatest = "MovieLatest";
|
||||
public const string MovieResume = "MovieResume";
|
||||
public const string MovieMovies = "MovieMovies";
|
||||
public const string MovieCollections = "MovieCollections";
|
||||
public const string MovieFavorites = "MovieFavorites";
|
||||
public const string MovieGenres = "MovieGenres";
|
||||
public const string MovieGenre = "MovieGenre";
|
||||
|
||||
public const string MusicArtists = "MusicArtists";
|
||||
public const string MusicAlbumArtists = "MusicAlbumArtists";
|
||||
public const string MusicAlbums = "MusicAlbums";
|
||||
public const string MusicGenres = "MusicGenres";
|
||||
public const string MusicLatest = "MusicLatest";
|
||||
public const string MusicPlaylists = "MusicPlaylists";
|
||||
public const string MusicSongs = "MusicSongs";
|
||||
public const string MusicFavorites = "MusicFavorites";
|
||||
public const string MusicFavoriteArtists = "MusicFavoriteArtists";
|
||||
public const string MusicFavoriteAlbums = "MusicFavoriteAlbums";
|
||||
public const string MusicFavoriteSongs = "MusicFavoriteSongs";
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
#nullable disable
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace MediaBrowser.Model.Net
|
||||
{
|
||||
/// <summary>
|
||||
/// Used by the sockets wrapper to hold raw data received from a UDP socket.
|
||||
/// </summary>
|
||||
public sealed class SocketReceiveResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the buffer to place received data into.
|
||||
/// </summary>
|
||||
public byte[] Buffer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of bytes received.
|
||||
/// </summary>
|
||||
public int ReceivedBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="IPEndPoint"/> the data was received from.
|
||||
/// </summary>
|
||||
public IPEndPoint RemoteEndPoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the local <see cref="IPAddress"/>.
|
||||
/// </summary>
|
||||
public IPAddress LocalIPAddress { get; set; }
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
|
||||
|
@ -30,5 +31,15 @@ namespace MediaBrowser.Model.Session
|
|||
public string AppStoreUrl { get; set; }
|
||||
|
||||
public string IconUrl { get; set; }
|
||||
|
||||
// TODO: Remove after 10.9
|
||||
[Obsolete("Unused")]
|
||||
[DefaultValue(false)]
|
||||
public bool? SupportsContentUploading { get; set; }
|
||||
|
||||
// TODO: Remove after 10.9
|
||||
[Obsolete("Unused")]
|
||||
[DefaultValue(false)]
|
||||
public bool? SupportsSync { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,11 @@
|
|||
/// <summary>
|
||||
/// Video ToolBox.
|
||||
/// </summary>
|
||||
VideoToolBox = 5
|
||||
VideoToolBox = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Rockchip Media Process Platform (RKMPP).
|
||||
/// </summary>
|
||||
RKMPP = 6
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,29 +3,11 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.ComponentModel;
|
||||
using MediaBrowser.Model.Updates;
|
||||
|
||||
namespace MediaBrowser.Model.System
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum describing the location of the FFmpeg tool.
|
||||
/// </summary>
|
||||
public enum FFmpegLocation
|
||||
{
|
||||
/// <summary>No path to FFmpeg found.</summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>Path supplied via command line using switch --ffmpeg.</summary>
|
||||
SetByArgument,
|
||||
|
||||
/// <summary>User has supplied path via Transcoding UI page.</summary>
|
||||
Custom,
|
||||
|
||||
/// <summary>FFmpeg tool found on system $PATH.</summary>
|
||||
System
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class SystemInfo.
|
||||
/// </summary>
|
||||
|
@ -83,9 +65,11 @@ namespace MediaBrowser.Model.System
|
|||
/// </summary>
|
||||
/// <value><c>true</c>.</value>
|
||||
[Obsolete("This is always true")]
|
||||
[DefaultValue(true)]
|
||||
public bool CanSelfRestart { get; set; } = true;
|
||||
|
||||
[Obsolete("This is always false")]
|
||||
[DefaultValue(false)]
|
||||
public bool CanLaunchWebBrowser { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
|
@ -140,12 +124,15 @@ namespace MediaBrowser.Model.System
|
|||
/// </summary>
|
||||
/// <value><c>true</c> if this instance has update available; otherwise, <c>false</c>.</value>
|
||||
[Obsolete("This should be handled by the package manager")]
|
||||
[DefaultValue(false)]
|
||||
public bool HasUpdateAvailable { get; set; }
|
||||
|
||||
[Obsolete("This isn't set correctly anymore")]
|
||||
public FFmpegLocation EncoderLocation { get; set; }
|
||||
[DefaultValue("System")]
|
||||
public string EncoderLocation { get; set; } = "System";
|
||||
|
||||
[Obsolete("This is no longer set")]
|
||||
public Architecture SystemArchitecture { get; set; } = Architecture.X64;
|
||||
[DefaultValue("X64")]
|
||||
public string SystemArchitecture { get; set; } = "X64";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ using Jellyfin.Data.Enums;
|
|||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.BaseItemManager;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
|
@ -1025,7 +1024,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
await RefreshCollectionFolderChildren(options, collectionFolder, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case Folder folder:
|
||||
await folder.ValidateChildren(new SimpleProgress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await folder.ValidateChildren(new Progress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -1036,7 +1035,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
await child.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await child.ValidateChildren(new SimpleProgress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await child.ValidateChildren(new Progress<double>(), options, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1058,7 +1057,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
.Select(i => i.MusicArtist)
|
||||
.Where(i => i is not null);
|
||||
|
||||
var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new SimpleProgress<double>(), options, true, cancellationToken));
|
||||
var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new Progress<double>(), options, true, cancellationToken));
|
||||
|
||||
await Task.WhenAll(musicArtistRefreshTasks).ConfigureAwait(false);
|
||||
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.XbmcMetadata.Configuration;
|
||||
using MediaBrowser.XbmcMetadata.Savers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.XbmcMetadata
|
||||
{
|
||||
public sealed class EntryPoint : IServerEntryPoint
|
||||
{
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILogger<EntryPoint> _logger;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly IConfigurationManager _config;
|
||||
|
||||
public EntryPoint(
|
||||
IUserDataManager userDataManager,
|
||||
ILogger<EntryPoint> logger,
|
||||
IProviderManager providerManager,
|
||||
IConfigurationManager config)
|
||||
{
|
||||
_userDataManager = userDataManager;
|
||||
_logger = logger;
|
||||
_providerManager = providerManager;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RunAsync()
|
||||
{
|
||||
_userDataManager.UserDataSaved += OnUserDataSaved;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||
{
|
||||
if (e.SaveReason == UserDataSaveReason.PlaybackFinished || e.SaveReason == UserDataSaveReason.TogglePlayed || e.SaveReason == UserDataSaveReason.UpdateUserRating)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_config.GetNfoConfiguration().UserId))
|
||||
{
|
||||
_ = SaveMetadataForItemAsync(e.Item, ItemUpdateType.MetadataDownload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
||||
}
|
||||
|
||||
private async Task SaveMetadataForItemAsync(BaseItem item, ItemUpdateType updateReason)
|
||||
{
|
||||
if (!item.IsFileProtocol || !item.SupportsLocalMetadata)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _providerManager.SaveMetadataAsync(item, updateReason, new[] { BaseNfoSaver.SaverName }).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving metadata for {Path}", item.Path ?? item.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.XbmcMetadata.Configuration;
|
||||
using MediaBrowser.XbmcMetadata.Savers;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.XbmcMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHostedService"/> responsible for updating NFO files' user data.
|
||||
/// </summary>
|
||||
public sealed class NfoUserDataSaver : IHostedService
|
||||
{
|
||||
private readonly ILogger<NfoUserDataSaver> _logger;
|
||||
private readonly IConfigurationManager _config;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NfoUserDataSaver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The <see cref="ILogger"/>.</param>
|
||||
/// <param name="config">The <see cref="IConfigurationManager"/>.</param>
|
||||
/// <param name="userDataManager">The <see cref="IUserDataManager"/>.</param>
|
||||
/// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
|
||||
public NfoUserDataSaver(
|
||||
ILogger<NfoUserDataSaver> logger,
|
||||
IConfigurationManager config,
|
||||
IUserDataManager userDataManager,
|
||||
IProviderManager providerManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_userDataManager = userDataManager;
|
||||
_providerManager = providerManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved += OnUserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||
{
|
||||
if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished
|
||||
or UserDataSaveReason.TogglePlayed or UserDataSaveReason.UpdateUserRating))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_config.GetNfoConfiguration().UserId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = e.Item;
|
||||
if (!item.IsFileProtocol || !item.SupportsLocalMetadata)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _providerManager.SaveMetadataAsync(item, ItemUpdateType.MetadataDownload, [BaseNfoSaver.SaverName])
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving metadata for {Path}", item.Path ?? item.Name);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -460,10 +460,28 @@ namespace MediaBrowser.XbmcMetadata.Parsers
|
|||
var trailer = reader.ReadNormalizedString();
|
||||
if (!string.IsNullOrEmpty(trailer))
|
||||
{
|
||||
item.AddTrailerUrl(trailer.Replace(
|
||||
"plugin://plugin.video.youtube/?action=play_video&videoid=",
|
||||
BaseNfoSaver.YouTubeWatchUrl,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (trailer.StartsWith("plugin://plugin.video.youtube/?action=play_video&videoid=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Deprecated format
|
||||
item.AddTrailerUrl(trailer.Replace(
|
||||
"plugin://plugin.video.youtube/?action=play_video&videoid=",
|
||||
BaseNfoSaver.YouTubeWatchUrl,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var suggestedUrl = trailer.Replace(
|
||||
"plugin://plugin.video.youtube/?action=play_video&videoid=",
|
||||
"plugin://plugin.video.youtube/play/?video_id=",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Logger.LogWarning("Trailer URL uses a deprecated format : {Url}. Using {NewUrl} instead is advised.", trailer, suggestedUrl);
|
||||
}
|
||||
else if (trailer.StartsWith("plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Proper format
|
||||
item.AddTrailerUrl(trailer.Replace(
|
||||
"plugin://plugin.video.youtube/play/?video_id=",
|
||||
BaseNfoSaver.YouTubeWatchUrl,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
27
README.md
27
README.md
|
@ -142,17 +142,36 @@ cd Jellyfin.Server/bin/Debug/net8.0 # Change into the build output directory
|
|||
|
||||
2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`.
|
||||
|
||||
#### Accessing the Hosted Web Client
|
||||
|
||||
If the Server is configured to host the Web Client, and the Server is running, the Web Client can be accessed at `http://localhost:8096` by default.
|
||||
|
||||
API documentation can be viewed at `http://localhost:8096/api-docs/swagger/index.html`
|
||||
|
||||
|
||||
### Running from GH-Codespaces
|
||||
|
||||
As Jellyfin will run on a container on a github hosted server, JF needs to handle some things differently.
|
||||
|
||||
**NOTE:** Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 secounds to load all extensions and prepare the enviorment while vscode is already open. Just give it some time and wait until you see `Downloading .NET version(s) 7.0.15~x64 ...... Done!` in the output tab.
|
||||
|
||||
**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VsCode window to public.
|
||||
|
||||
#### FFmpeg installation.
|
||||
Because sometimes you need FFMPEG to test certain cases, follow the instructions from the wiki on the dev enviorment:
|
||||
https://jellyfin.org/docs/general/installation/linux/#ffmpeg-installation
|
||||
|
||||
**NOTE:** When first opening the server instance with any WebUI, you will be send to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup.
|
||||
|
||||
There are two configurations for you to chose from.
|
||||
#### Default - Development Jellyfin Server
|
||||
This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run though the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the `.NET Launch (nowebclient)` lunch config to start the server.
|
||||
|
||||
> Keep in mind that as this has no web client you have to connect to it via an extenal client. This can be just another codespace container running the WebUI. vuejs does not work from the getgo as it does not support the setup steps.
|
||||
|
||||
#### Development Jellyfin Server ffmpeg
|
||||
this extens the default server with an default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual
|
||||
If you want to install a specific ffmpeg version, follow the comments embedded in the `.devcontainer/Dev - Server Ffmpeg/install.ffmpeg.sh` file.
|
||||
|
||||
Use the `ghcs .NET Launch (nowebclient, ffmpeg)` launch config to run with the jellyfin-ffmpeg enabled.
|
||||
|
||||
|
||||
### Running The Tests
|
||||
|
||||
This repository also includes unit tests that are used to validate functionality as part of a CI pipeline on Azure. There are several ways to run these tests.
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
using System.Globalization;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Jellyfin.Drawing.Skia;
|
||||
|
||||
/// <summary>
|
||||
/// Represents errors that occur during interaction with Skia codecs.
|
||||
/// </summary>
|
||||
public class SkiaCodecException : SkiaException
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkiaCodecException" /> class.
|
||||
/// </summary>
|
||||
/// <param name="result">The non-successful codec result returned by Skia.</param>
|
||||
public SkiaCodecException(SKCodecResult result)
|
||||
{
|
||||
CodecResult = result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkiaCodecException" /> class
|
||||
/// with a specified error message.
|
||||
/// </summary>
|
||||
/// <param name="result">The non-successful codec result returned by Skia.</param>
|
||||
/// <param name="message">The message that describes the error.</param>
|
||||
public SkiaCodecException(SKCodecResult result, string message)
|
||||
: base(message)
|
||||
{
|
||||
CodecResult = result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the non-successful codec result returned by Skia.
|
||||
/// </summary>
|
||||
public SKCodecResult CodecResult { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
=> string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Non-success codec result: {0}\n{1}",
|
||||
CodecResult,
|
||||
base.ToString());
|
||||
}
|
|
@ -182,7 +182,6 @@ public class SkiaEncoder : IImageEncoder
|
|||
/// <inheritdoc />
|
||||
/// <exception cref="ArgumentNullException">The path is null.</exception>
|
||||
/// <exception cref="FileNotFoundException">The path is not valid.</exception>
|
||||
/// <exception cref="SkiaCodecException">The file at the specified path could not be used to generate a codec.</exception>
|
||||
public string GetImageBlurHash(int xComp, int yComp, string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
using System;
|
||||
|
||||
namespace Jellyfin.Drawing.Skia;
|
||||
|
||||
/// <summary>
|
||||
/// Represents errors that occur during interaction with Skia.
|
||||
/// </summary>
|
||||
public class SkiaException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkiaException"/> class.
|
||||
/// </summary>
|
||||
public SkiaException()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message.
|
||||
/// </summary>
|
||||
/// <param name="message">The message that describes the error.</param>
|
||||
public SkiaException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SkiaException"/> class with a specified error message and a
|
||||
/// reference to the inner exception that is the cause of this exception.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message that explains the reason for the exception.</param>
|
||||
/// <param name="innerException">
|
||||
/// The exception that is the cause of the current exception, or a null reference (Nothing in Visual Basic) if
|
||||
/// no inner exception is specified.
|
||||
/// </param>
|
||||
public SkiaException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ using System.Net.Mime;
|
|||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Data.Entities;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
|
@ -38,7 +39,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
|
|||
private readonly IServerApplicationPaths _appPaths;
|
||||
private readonly IImageEncoder _imageEncoder;
|
||||
|
||||
private readonly SemaphoreSlim _parallelEncodingLimit;
|
||||
private readonly AsyncNonKeyedLocker _parallelEncodingLimit;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
|
@ -68,7 +69,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
|
|||
semaphoreCount = 2 * Environment.ProcessorCount;
|
||||
}
|
||||
|
||||
_parallelEncodingLimit = new(semaphoreCount, semaphoreCount);
|
||||
_parallelEncodingLimit = new(semaphoreCount);
|
||||
}
|
||||
|
||||
private string ResizedImageCachePath => Path.Combine(_appPaths.ImageCachePath, "resized-images");
|
||||
|
@ -193,18 +194,13 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
|
|||
{
|
||||
if (!File.Exists(cacheFilePath))
|
||||
{
|
||||
// Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage
|
||||
await _parallelEncodingLimit.WaitAsync().ConfigureAwait(false);
|
||||
|
||||
string resultPath;
|
||||
try
|
||||
|
||||
// Limit number of parallel (more precisely: concurrent) image encodings to prevent a high memory usage
|
||||
using (await _parallelEncodingLimit.LockAsync().ConfigureAwait(false))
|
||||
{
|
||||
resultPath = _imageEncoder.EncodeImage(originalImagePath, dateModified, cacheFilePath, autoOrient, orientation, quality, options, outputFormat);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_parallelEncodingLimit.Release();
|
||||
}
|
||||
|
||||
if (string.Equals(resultPath, originalImagePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
|
@ -21,4 +21,8 @@
|
|||
<Compile Include="..\..\SharedVersion.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -8,12 +8,12 @@ using System.Linq;
|
|||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
|
@ -50,7 +50,7 @@ namespace Jellyfin.LiveTv.Channels
|
|||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly SemaphoreSlim _resourcePool = new SemaphoreSlim(1, 1);
|
||||
private readonly AsyncNonKeyedLocker _resourcePool = new(1);
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
private bool _disposed = false;
|
||||
|
||||
|
@ -667,7 +667,7 @@ namespace Jellyfin.LiveTv.Channels
|
|||
ChannelIds = new Guid[] { internalChannel.Id }
|
||||
};
|
||||
|
||||
var result = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false);
|
||||
var result = await GetChannelItemsInternal(query, new Progress<double>(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var item in result.Items)
|
||||
{
|
||||
|
@ -680,7 +680,7 @@ namespace Jellyfin.LiveTv.Channels
|
|||
EnableTotalRecordCount = false,
|
||||
ChannelIds = new Guid[] { internalChannel.Id }
|
||||
},
|
||||
new SimpleProgress<double>(),
|
||||
new Progress<double>(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
@ -762,7 +762,7 @@ namespace Jellyfin.LiveTv.Channels
|
|||
/// <inheritdoc />
|
||||
public async Task<QueryResult<BaseItemDto>> GetChannelItems(InternalItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var internalResult = await GetChannelItemsInternal(query, new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false);
|
||||
var internalResult = await GetChannelItemsInternal(query, new Progress<double>(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var returnItems = _dtoService.GetBaseItemDtos(internalResult.Items, query.DtoOptions, query.User);
|
||||
|
||||
|
@ -811,9 +811,7 @@ namespace Jellyfin.LiveTv.Channels
|
|||
{
|
||||
}
|
||||
|
||||
await _resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -860,10 +858,6 @@ namespace Jellyfin.LiveTv.Channels
|
|||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_resourcePool.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CacheResponse(ChannelItemResult result, string path)
|
||||
|
|
|
@ -2,7 +2,6 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
|
@ -66,7 +65,7 @@ namespace Jellyfin.LiveTv.Channels
|
|||
{
|
||||
var manager = (ChannelManager)_channelManager;
|
||||
|
||||
await manager.RefreshChannels(new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false);
|
||||
await manager.RefreshChannels(new Progress<double>(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await new ChannelPostScanTask(_channelManager, _logger, _libraryManager).Run(progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
|
|
@ -14,13 +14,13 @@ using System.Text;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
@ -51,7 +51,6 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider;
|
||||
private readonly TimerManager _timerProvider;
|
||||
|
||||
private readonly LiveTvManager _liveTvManager;
|
||||
private readonly ITunerHostManager _tunerHostManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
|
@ -61,6 +60,8 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IStreamHelper _streamHelper;
|
||||
private readonly LiveTvDtoService _tvDtoService;
|
||||
private readonly IListingsProvider[] _listingsProviders;
|
||||
|
||||
private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings =
|
||||
new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
@ -68,7 +69,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels =
|
||||
new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1);
|
||||
private readonly AsyncNonKeyedLocker _recordingDeleteSemaphore = new(1);
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
|
@ -78,13 +79,14 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
ILogger<EmbyTV> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServerConfigurationManager config,
|
||||
ILiveTvManager liveTvManager,
|
||||
ITunerHostManager tunerHostManager,
|
||||
IFileSystem fileSystem,
|
||||
ILibraryManager libraryManager,
|
||||
ILibraryMonitor libraryMonitor,
|
||||
IProviderManager providerManager,
|
||||
IMediaEncoder mediaEncoder)
|
||||
IMediaEncoder mediaEncoder,
|
||||
LiveTvDtoService tvDtoService,
|
||||
IEnumerable<IListingsProvider> listingsProviders)
|
||||
{
|
||||
Current = this;
|
||||
|
||||
|
@ -96,10 +98,11 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
_libraryMonitor = libraryMonitor;
|
||||
_providerManager = providerManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_liveTvManager = (LiveTvManager)liveTvManager;
|
||||
_tvDtoService = tvDtoService;
|
||||
_tunerHostManager = tunerHostManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_streamHelper = streamHelper;
|
||||
_listingsProviders = listingsProviders.ToArray();
|
||||
|
||||
_seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json"));
|
||||
_timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json"));
|
||||
|
@ -257,7 +260,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
|
||||
if (requiresRefresh)
|
||||
{
|
||||
await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -937,7 +940,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
return _config.GetLiveTvConfiguration().ListingProviders
|
||||
.Select(i =>
|
||||
{
|
||||
var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase));
|
||||
var provider = _listingsProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return provider is null ? null : new Tuple<IListingsProvider, ListingsProviderInfo>(provider, i);
|
||||
})
|
||||
|
@ -1181,6 +1184,12 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
return Path.Combine(recordPath, recordingFileName);
|
||||
}
|
||||
|
||||
private BaseItem GetLiveTvChannel(TimerInfo timer)
|
||||
{
|
||||
var internalChannelId = _tvDtoService.GetInternalChannelId(Name, timer.ChannelId);
|
||||
return _libraryManager.GetItemById(internalChannelId);
|
||||
}
|
||||
|
||||
private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timer);
|
||||
|
@ -1206,7 +1215,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false);
|
||||
var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath);
|
||||
|
||||
var channelItem = _liveTvManager.GetLiveTvChannel(timer, this);
|
||||
var channelItem = GetLiveTvChannel(timer);
|
||||
|
||||
string liveStreamId = null;
|
||||
RecordingStatus recordingStatus;
|
||||
|
@ -1444,9 +1453,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
return;
|
||||
}
|
||||
|
||||
await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
using (await _recordingDeleteSemaphore.LockAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
|
@ -1499,10 +1506,6 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_recordingDeleteSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteLibraryItemsForTimers(List<TimerInfo> timers)
|
||||
|
@ -2089,7 +2092,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
{
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) },
|
||||
ItemIds = [_tvDtoService.GetInternalProgramId(programId)],
|
||||
Limit = 1,
|
||||
DtoOptions = new DtoOptions()
|
||||
};
|
||||
|
@ -2119,7 +2122,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
|
||||
if (!string.IsNullOrWhiteSpace(channelId))
|
||||
{
|
||||
query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) };
|
||||
query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, channelId)];
|
||||
}
|
||||
|
||||
return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().FirstOrDefault();
|
||||
|
@ -2155,7 +2158,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
private void HandleDuplicateShowIds(List<TimerInfo> timers)
|
||||
{
|
||||
// sort showings by HD channels first, then by startDate, record earliest showing possible
|
||||
foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1))
|
||||
foreach (var timer in timers.OrderByDescending(t => GetLiveTvChannel(t).IsHD).ThenBy(t => t.StartDate).Skip(1))
|
||||
{
|
||||
timer.Status = RecordingStatus.Cancelled;
|
||||
_timerProvider.Update(timer);
|
||||
|
@ -2305,7 +2308,7 @@ namespace Jellyfin.LiveTv.EmbyTV
|
|||
|
||||
if (!seriesTimer.RecordAnyChannel)
|
||||
{
|
||||
query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) };
|
||||
query.ChannelIds = [_tvDtoService.GetInternalChannelId(Name, seriesTimer.ChannelId)];
|
||||
}
|
||||
|
||||
var tempChannelCache = new Dictionary<Guid, LiveTvChannel>();
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
|
||||
namespace Jellyfin.LiveTv.EmbyTV
|
||||
{
|
||||
public sealed class EntryPoint : IServerEntryPoint
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task RunAsync()
|
||||
{
|
||||
return EmbyTV.Current.Start();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Jellyfin.LiveTv.EmbyTV;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHostedService"/> responsible for initializing Live TV.
|
||||
/// </summary>
|
||||
public sealed class LiveTvHost : IHostedService
|
||||
{
|
||||
private readonly EmbyTV _service;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LiveTvHost"/> class.
|
||||
/// </summary>
|
||||
/// <param name="services">The available <see cref="ILiveTvService"/>s.</param>
|
||||
public LiveTvHost(IEnumerable<ILiveTvService> services)
|
||||
{
|
||||
_service = services.OfType<EmbyTV>().First();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken) => _service.Start();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
using Jellyfin.LiveTv.Channels;
|
||||
using Jellyfin.LiveTv.Guide;
|
||||
using Jellyfin.LiveTv.Listings;
|
||||
using Jellyfin.LiveTv.TunerHosts;
|
||||
using Jellyfin.LiveTv.TunerHosts.HdHomerun;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
|
@ -24,8 +26,12 @@ public static class LiveTvServiceCollectionExtensions
|
|||
services.AddSingleton<IChannelManager, ChannelManager>();
|
||||
services.AddSingleton<IStreamHelper, StreamHelper>();
|
||||
services.AddSingleton<ITunerHostManager, TunerHostManager>();
|
||||
services.AddSingleton<IGuideManager, GuideManager>();
|
||||
|
||||
services.AddSingleton<ILiveTvService, EmbyTV.EmbyTV>();
|
||||
services.AddSingleton<ITunerHost, HdHomerunHost>();
|
||||
services.AddSingleton<ITunerHost, M3UTunerHost>();
|
||||
services.AddSingleton<IListingsProvider, SchedulesDirect>();
|
||||
services.AddSingleton<IListingsProvider, XmlTvListingsProvider>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,707 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.LiveTv;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.LiveTv.Guide;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class GuideManager : IGuideManager
|
||||
{
|
||||
private const int MaxGuideDays = 14;
|
||||
private const string EtagKey = "ProgramEtag";
|
||||
private const string ExternalServiceTag = "ExternalServiceId";
|
||||
|
||||
private readonly ILogger<GuideManager> _logger;
|
||||
private readonly IConfigurationManager _config;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IItemRepository _itemRepo;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILiveTvManager _liveTvManager;
|
||||
private readonly ITunerHostManager _tunerHostManager;
|
||||
private readonly LiveTvDtoService _tvDtoService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GuideManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
|
||||
/// <param name="config">The <see cref="IConfigurationManager"/>.</param>
|
||||
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
|
||||
/// <param name="itemRepo">The <see cref="IItemRepository"/>.</param>
|
||||
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||
/// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
|
||||
/// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
|
||||
/// <param name="tvDtoService">The <see cref="LiveTvDtoService"/>.</param>
|
||||
public GuideManager(
|
||||
ILogger<GuideManager> logger,
|
||||
IConfigurationManager config,
|
||||
IFileSystem fileSystem,
|
||||
IItemRepository itemRepo,
|
||||
ILibraryManager libraryManager,
|
||||
ILiveTvManager liveTvManager,
|
||||
ITunerHostManager tunerHostManager,
|
||||
LiveTvDtoService tvDtoService)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_fileSystem = fileSystem;
|
||||
_itemRepo = itemRepo;
|
||||
_libraryManager = libraryManager;
|
||||
_liveTvManager = liveTvManager;
|
||||
_tunerHostManager = tunerHostManager;
|
||||
_tvDtoService = tvDtoService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public GuideInfo GetGuideInfo()
|
||||
{
|
||||
var startDate = DateTime.UtcNow;
|
||||
var endDate = startDate.AddDays(GetGuideDays());
|
||||
|
||||
return new GuideInfo
|
||||
{
|
||||
StartDate = startDate,
|
||||
EndDate = endDate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RefreshGuide(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(progress);
|
||||
|
||||
await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false);
|
||||
|
||||
await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var numComplete = 0;
|
||||
double progressPerService = _liveTvManager.Services.Count == 0
|
||||
? 0
|
||||
: 1.0 / _liveTvManager.Services.Count;
|
||||
|
||||
var newChannelIdList = new List<Guid>();
|
||||
var newProgramIdList = new List<Guid>();
|
||||
|
||||
var cleanDatabase = true;
|
||||
|
||||
foreach (var service in _liveTvManager.Services)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug("Refreshing guide from {Name}", service.Name);
|
||||
|
||||
try
|
||||
{
|
||||
var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService));
|
||||
|
||||
var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
newChannelIdList.AddRange(idList.Item1);
|
||||
newProgramIdList.AddRange(idList.Item2);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
cleanDatabase = false;
|
||||
_logger.LogError(ex, "Error refreshing channels for service");
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete;
|
||||
percent /= _liveTvManager.Services.Count;
|
||||
|
||||
progress.Report(100 * percent);
|
||||
}
|
||||
|
||||
if (cleanDatabase)
|
||||
{
|
||||
CleanDatabase(newChannelIdList.ToArray(), [BaseItemKind.LiveTvChannel], progress, cancellationToken);
|
||||
CleanDatabase(newProgramIdList.ToArray(), [BaseItemKind.LiveTvProgram], progress, cancellationToken);
|
||||
}
|
||||
|
||||
var coreService = _liveTvManager.Services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
|
||||
if (coreService is not null)
|
||||
{
|
||||
await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
|
||||
await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private double GetGuideDays()
|
||||
{
|
||||
var config = _config.GetLiveTvConfiguration();
|
||||
|
||||
return config.GuideDays.HasValue
|
||||
? Math.Clamp(config.GuideDays.Value, 1, MaxGuideDays)
|
||||
: 7;
|
||||
}
|
||||
|
||||
private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
progress.Report(10);
|
||||
|
||||
var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
|
||||
.Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
|
||||
.ToList();
|
||||
|
||||
var list = new List<LiveTvChannel>();
|
||||
|
||||
var numComplete = 0;
|
||||
var parentFolder = _liveTvManager.GetInternalLiveTvFolder(cancellationToken);
|
||||
|
||||
foreach (var channelInfo in allChannelsList)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
list.Add(item);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete;
|
||||
percent /= allChannelsList.Count;
|
||||
|
||||
progress.Report((5 * percent) + 10);
|
||||
}
|
||||
|
||||
progress.Report(15);
|
||||
|
||||
numComplete = 0;
|
||||
var programs = new List<Guid>();
|
||||
var channels = new List<Guid>();
|
||||
|
||||
var guideDays = GetGuideDays();
|
||||
|
||||
_logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
|
||||
|
||||
foreach (var currentChannel in list)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
channels.Add(currentChannel.Id);
|
||||
|
||||
try
|
||||
{
|
||||
var start = DateTime.UtcNow.AddHours(-1);
|
||||
var end = start.AddDays(guideDays);
|
||||
|
||||
var isMovie = false;
|
||||
var isSports = false;
|
||||
var isNews = false;
|
||||
var isKids = false;
|
||||
var isSeries = false;
|
||||
|
||||
var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
|
||||
var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.LiveTvProgram],
|
||||
ChannelIds = [currentChannel.Id],
|
||||
DtoOptions = new DtoOptions(true)
|
||||
}).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
|
||||
|
||||
var newPrograms = new List<LiveTvProgram>();
|
||||
var updatedPrograms = new List<BaseItem>();
|
||||
|
||||
foreach (var program in channelPrograms)
|
||||
{
|
||||
var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
|
||||
if (isNew)
|
||||
{
|
||||
newPrograms.Add(programItem);
|
||||
}
|
||||
else if (isUpdated)
|
||||
{
|
||||
updatedPrograms.Add(programItem);
|
||||
}
|
||||
|
||||
programs.Add(programItem.Id);
|
||||
|
||||
isMovie |= program.IsMovie;
|
||||
isSeries |= program.IsSeries;
|
||||
isSports |= program.IsSports;
|
||||
isNews |= program.IsNews;
|
||||
isKids |= program.IsKids;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count);
|
||||
|
||||
if (newPrograms.Count > 0)
|
||||
{
|
||||
_libraryManager.CreateItems(newPrograms, null, cancellationToken);
|
||||
}
|
||||
|
||||
if (updatedPrograms.Count > 0)
|
||||
{
|
||||
await _libraryManager.UpdateItemsAsync(
|
||||
updatedPrograms,
|
||||
currentChannel,
|
||||
ItemUpdateType.MetadataImport,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
currentChannel.IsMovie = isMovie;
|
||||
currentChannel.IsNews = isNews;
|
||||
currentChannel.IsSports = isSports;
|
||||
currentChannel.IsSeries = isSeries;
|
||||
|
||||
if (isKids)
|
||||
{
|
||||
currentChannel.AddTag("Kids");
|
||||
}
|
||||
|
||||
await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||
await currentChannel.RefreshMetadata(
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ForceSave = true
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete / (double)allChannelsList.Count;
|
||||
|
||||
progress.Report((85 * percent) + 15);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
return new Tuple<List<Guid>, List<Guid>>(channels, programs);
|
||||
}
|
||||
|
||||
private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = validTypes,
|
||||
DtoOptions = new DtoOptions(false)
|
||||
});
|
||||
|
||||
var numComplete = 0;
|
||||
|
||||
foreach (var itemId in list)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (itemId.IsEmpty())
|
||||
{
|
||||
// Somehow some invalid data got into the db. It probably predates the boundary checking
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentIdList.Contains(itemId))
|
||||
{
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
|
||||
if (item is not null)
|
||||
{
|
||||
_libraryManager.DeleteItem(
|
||||
item,
|
||||
new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false,
|
||||
DeleteFromExternalProvider = false
|
||||
},
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete / (double)list.Count;
|
||||
|
||||
progress.Report(100 * percent);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LiveTvChannel> GetChannel(
|
||||
ChannelInfo channelInfo,
|
||||
string serviceName,
|
||||
BaseItem parentFolder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parentFolderId = parentFolder.Id;
|
||||
var isNew = false;
|
||||
var forceUpdate = false;
|
||||
|
||||
var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
|
||||
|
||||
if (_libraryManager.GetItemById(id) is not LiveTvChannel item)
|
||||
{
|
||||
item = new LiveTvChannel
|
||||
{
|
||||
Name = channelInfo.Name,
|
||||
Id = id,
|
||||
DateCreated = DateTime.UtcNow
|
||||
};
|
||||
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
if (channelInfo.Tags is not null)
|
||||
{
|
||||
if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
item.Tags = channelInfo.Tags;
|
||||
}
|
||||
|
||||
if (!item.ParentId.Equals(parentFolderId))
|
||||
{
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
item.ParentId = parentFolderId;
|
||||
|
||||
item.ChannelType = channelInfo.ChannelType;
|
||||
item.ServiceName = serviceName;
|
||||
|
||||
if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.SetProviderId(ExternalServiceTag, serviceName);
|
||||
|
||||
if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.ExternalId = channelInfo.Id;
|
||||
|
||||
if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.Number = channelInfo.Number;
|
||||
|
||||
if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.Name = channelInfo.Name;
|
||||
|
||||
if (!item.HasImage(ImageType.Primary))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
|
||||
{
|
||||
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
|
||||
forceUpdate = true;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
|
||||
{
|
||||
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
|
||||
forceUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_libraryManager.CreateItem(item, parentFolder);
|
||||
}
|
||||
else if (forceUpdate)
|
||||
{
|
||||
await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(
|
||||
ProgramInfo info,
|
||||
Dictionary<Guid, LiveTvProgram> allExistingPrograms,
|
||||
LiveTvChannel channel)
|
||||
{
|
||||
var id = _tvDtoService.GetInternalProgramId(info.Id);
|
||||
|
||||
var isNew = false;
|
||||
var forceUpdate = false;
|
||||
|
||||
if (!allExistingPrograms.TryGetValue(id, out var item))
|
||||
{
|
||||
isNew = true;
|
||||
item = new LiveTvProgram
|
||||
{
|
||||
Name = info.Name,
|
||||
Id = id,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(info.Etag))
|
||||
{
|
||||
item.SetProviderId(EtagKey, info.Etag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.ShowId = info.ShowId;
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
var seriesId = info.SeriesId;
|
||||
|
||||
if (!item.ParentId.Equals(channel.Id))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.ParentId = channel.Id;
|
||||
|
||||
item.Audio = info.Audio;
|
||||
item.ChannelId = channel.Id;
|
||||
item.CommunityRating ??= info.CommunityRating;
|
||||
if ((item.CommunityRating ?? 0).Equals(0))
|
||||
{
|
||||
item.CommunityRating = null;
|
||||
}
|
||||
|
||||
item.EpisodeTitle = info.EpisodeTitle;
|
||||
item.ExternalId = info.Id;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.ExternalSeriesId = seriesId;
|
||||
|
||||
var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
|
||||
|
||||
if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
|
||||
{
|
||||
item.SeriesName = info.Name;
|
||||
}
|
||||
|
||||
var tags = new List<string>();
|
||||
if (info.IsLive)
|
||||
{
|
||||
tags.Add("Live");
|
||||
}
|
||||
|
||||
if (info.IsPremiere)
|
||||
{
|
||||
tags.Add("Premiere");
|
||||
}
|
||||
|
||||
if (info.IsNews)
|
||||
{
|
||||
tags.Add("News");
|
||||
}
|
||||
|
||||
if (info.IsSports)
|
||||
{
|
||||
tags.Add("Sports");
|
||||
}
|
||||
|
||||
if (info.IsKids)
|
||||
{
|
||||
tags.Add("Kids");
|
||||
}
|
||||
|
||||
if (info.IsRepeat)
|
||||
{
|
||||
tags.Add("Repeat");
|
||||
}
|
||||
|
||||
if (info.IsMovie)
|
||||
{
|
||||
tags.Add("Movie");
|
||||
}
|
||||
|
||||
if (isSeries)
|
||||
{
|
||||
tags.Add("Series");
|
||||
}
|
||||
|
||||
item.Tags = tags.ToArray();
|
||||
|
||||
item.Genres = info.Genres.ToArray();
|
||||
|
||||
if (info.IsHD ?? false)
|
||||
{
|
||||
item.Width = 1280;
|
||||
item.Height = 720;
|
||||
}
|
||||
|
||||
item.IsMovie = info.IsMovie;
|
||||
item.IsRepeat = info.IsRepeat;
|
||||
|
||||
if (item.IsSeries != isSeries)
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.IsSeries = isSeries;
|
||||
|
||||
item.Name = info.Name;
|
||||
item.OfficialRating ??= info.OfficialRating;
|
||||
item.Overview ??= info.Overview;
|
||||
item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
|
||||
item.ProviderIds = info.ProviderIds;
|
||||
|
||||
foreach (var providerId in info.SeriesProviderIds)
|
||||
{
|
||||
info.ProviderIds["Series" + providerId.Key] = providerId.Value;
|
||||
}
|
||||
|
||||
if (item.StartDate != info.StartDate)
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.StartDate = info.StartDate;
|
||||
|
||||
if (item.EndDate != info.EndDate)
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.EndDate = info.EndDate;
|
||||
|
||||
item.ProductionYear = info.ProductionYear;
|
||||
|
||||
if (!isSeries || info.IsRepeat)
|
||||
{
|
||||
item.PremiereDate = info.OriginalAirDate;
|
||||
}
|
||||
|
||||
item.IndexNumber = info.EpisodeNumber;
|
||||
item.ParentIndexNumber = info.SeasonNumber;
|
||||
|
||||
if (!item.HasImage(ImageType.Primary))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.ImagePath))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.ImagePath,
|
||||
Type = ImageType.Primary
|
||||
},
|
||||
0);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.ImageUrl,
|
||||
Type = ImageType.Primary
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.HasImage(ImageType.Thumb))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.ThumbImageUrl,
|
||||
Type = ImageType.Thumb
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.HasImage(ImageType.Logo))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.LogoImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.LogoImageUrl,
|
||||
Type = ImageType.Logo
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.HasImage(ImageType.Backdrop))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.BackdropImageUrl,
|
||||
Type = ImageType.Backdrop
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
var isUpdated = false;
|
||||
if (isNew)
|
||||
{
|
||||
}
|
||||
else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
|
||||
{
|
||||
isUpdated = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var etag = info.Etag;
|
||||
|
||||
if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.SetProviderId(EtagKey, etag);
|
||||
isUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNew || isUpdated)
|
||||
{
|
||||
item.OnMetadataChanged();
|
||||
}
|
||||
|
||||
return (item, isNew, isUpdated);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
|
||||
namespace Jellyfin.LiveTv.Guide;
|
||||
|
||||
/// <summary>
|
||||
/// The "Refresh Guide" scheduled task.
|
||||
/// </summary>
|
||||
public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask
|
||||
{
|
||||
private readonly ILiveTvManager _liveTvManager;
|
||||
private readonly IGuideManager _guideManager;
|
||||
private readonly IConfigurationManager _config;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="liveTvManager">The live tv manager.</param>
|
||||
/// <param name="guideManager">The guide manager.</param>
|
||||
/// <param name="config">The configuration manager.</param>
|
||||
public RefreshGuideScheduledTask(
|
||||
ILiveTvManager liveTvManager,
|
||||
IGuideManager guideManager,
|
||||
IConfigurationManager config)
|
||||
{
|
||||
_liveTvManager = liveTvManager;
|
||||
_guideManager = guideManager;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Refresh Guide";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Downloads channel information from live tv services.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "Live TV";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLogged => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "RefreshGuide";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
=> _guideManager.RefreshGuide(progress, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromHours(24).Ticks
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
@ -11,6 +11,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
<PackageReference Include="Jellyfin.XmlTv" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -16,6 +16,7 @@ using System.Text;
|
|||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
|
||||
|
@ -35,7 +36,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||
|
||||
private readonly ILogger<SchedulesDirect> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
|
||||
private readonly AsyncNonKeyedLocker _tokenLock = new(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
@ -105,8 +106,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||
using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules");
|
||||
options.Content = JsonContent.Create(requestList, options: _jsonOptions);
|
||||
options.Headers.TryAddWithoutValidation("token", token);
|
||||
using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
|
||||
var dailySchedules = await response.Content.ReadFromJsonAsync<IReadOnlyList<DayDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var dailySchedules = await Request<IReadOnlyList<DayDto>>(options, true, info, cancellationToken).ConfigureAwait(false);
|
||||
if (dailySchedules is null)
|
||||
{
|
||||
return Array.Empty<ProgramInfo>();
|
||||
|
@ -120,8 +120,8 @@ namespace Jellyfin.LiveTv.Listings
|
|||
var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct();
|
||||
programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions);
|
||||
|
||||
using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false);
|
||||
var programDetails = await innerResponse.Content.ReadFromJsonAsync<IReadOnlyList<ProgramDetailsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (programDetails is null)
|
||||
{
|
||||
return Array.Empty<ProgramInfo>();
|
||||
|
@ -471,16 +471,13 @@ namespace Jellyfin.LiveTv.Listings
|
|||
str.Length--;
|
||||
str.Append(']');
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs")
|
||||
{
|
||||
Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json)
|
||||
};
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs");
|
||||
message.Headers.TryAddWithoutValidation("token", token);
|
||||
message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json);
|
||||
|
||||
try
|
||||
{
|
||||
using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false);
|
||||
return await innerResponse2.Content.ReadFromJsonAsync<IReadOnlyList<ShowImagesDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@ -506,8 +503,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||
|
||||
try
|
||||
{
|
||||
using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false);
|
||||
var root = await httpResponse.Content.ReadFromJsonAsync<IReadOnlyList<HeadendsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false);
|
||||
if (root is not null)
|
||||
{
|
||||
foreach (HeadendsDto headend in root)
|
||||
|
@ -573,60 +569,64 @@ namespace Jellyfin.LiveTv.Listings
|
|||
}
|
||||
}
|
||||
|
||||
await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false);
|
||||
savedToken.Name = result;
|
||||
savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
|
||||
try
|
||||
{
|
||||
_tokens.Clear();
|
||||
_lastErrorResponse = DateTime.UtcNow;
|
||||
var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false);
|
||||
savedToken.Name = result;
|
||||
savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
|
||||
{
|
||||
_tokens.Clear();
|
||||
_lastErrorResponse = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tokenSemaphore.Release();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> Send(
|
||||
HttpRequestMessage options,
|
||||
private async Task<T> Request<T>(
|
||||
HttpRequestMessage message,
|
||||
bool enableRetry,
|
||||
ListingsProviderInfo providerInfo,
|
||||
CancellationToken cancellationToken,
|
||||
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
|
||||
{
|
||||
var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.SendAsync(message, completionOption, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return response;
|
||||
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Response is automatically disposed in the calling function,
|
||||
// so dispose manually if not returning.
|
||||
#pragma warning disable IDISP016, IDISP017
|
||||
response.Dispose();
|
||||
if (!enableRetry || (int)response.StatusCode >= 500)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Request to {Url} failed with response {Response}",
|
||||
message.RequestUri,
|
||||
await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
|
||||
|
||||
throw new HttpRequestException(
|
||||
string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
#pragma warning restore IDISP016, IDISP017
|
||||
|
||||
_tokens.Clear();
|
||||
options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
|
||||
return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false);
|
||||
using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
|
||||
retryMessage.Content = message.Content;
|
||||
retryMessage.Headers.TryAddWithoutValidation(
|
||||
"token",
|
||||
await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<string> GetTokenInternal(
|
||||
|
@ -643,9 +643,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||
string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
|
||||
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
|
||||
|
||||
using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var root = await response.Content.ReadFromJsonAsync<TokenDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
|
||||
if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
|
||||
|
@ -662,11 +660,21 @@ namespace Jellyfin.LiveTv.Listings
|
|||
ArgumentException.ThrowIfNullOrEmpty(token);
|
||||
ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
|
||||
|
||||
_logger.LogInformation("Adding new LineUp ");
|
||||
_logger.LogInformation("Adding new lineup {Id}", info.ListingsId);
|
||||
|
||||
using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
|
||||
options.Headers.TryAddWithoutValidation("token", token);
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
|
||||
message.Headers.TryAddWithoutValidation("token", token);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Error adding lineup to account: {Response}",
|
||||
await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
|
||||
|
@ -684,9 +692,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||
|
||||
try
|
||||
{
|
||||
using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
|
||||
httpResponse.EnsureSuccessStatusCode();
|
||||
var root = await httpResponse.Content.ReadFromJsonAsync<LineupsDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var root = await Request<LineupsDto>(options, false, null, cancellationToken).ConfigureAwait(false);
|
||||
return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
|
@ -739,8 +745,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
|
||||
options.Headers.TryAddWithoutValidation("token", token);
|
||||
|
||||
using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
|
||||
var root = await httpResponse.Content.ReadFromJsonAsync<ChannelDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
var root = await Request<ChannelDto>(options, true, info, cancellationToken).ConfigureAwait(false);
|
||||
if (root is null)
|
||||
{
|
||||
return new List<ChannelInfo>();
|
||||
|
@ -801,7 +806,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||
|
||||
if (disposing)
|
||||
{
|
||||
_tokenSemaphore?.Dispose();
|
||||
_tokenLock?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
|
|
@ -12,23 +12,19 @@ using System.Threading.Tasks;
|
|||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using Jellyfin.LiveTv.Guide;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Progress;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Sorting;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.LiveTv;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
|
@ -41,56 +37,49 @@ namespace Jellyfin.LiveTv
|
|||
/// </summary>
|
||||
public class LiveTvManager : ILiveTvManager
|
||||
{
|
||||
private const int MaxGuideDays = 14;
|
||||
private const string ExternalServiceTag = "ExternalServiceId";
|
||||
|
||||
private const string EtagKey = "ProgramEtag";
|
||||
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly ILogger<LiveTvManager> _logger;
|
||||
private readonly IItemRepository _itemRepo;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ITaskManager _taskManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IChannelManager _channelManager;
|
||||
private readonly LiveTvDtoService _tvDtoService;
|
||||
private readonly ITunerHostManager _tunerHostManager;
|
||||
|
||||
private ILiveTvService[] _services = Array.Empty<ILiveTvService>();
|
||||
private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>();
|
||||
private readonly ILiveTvService[] _services;
|
||||
private readonly IListingsProvider[] _listingProviders;
|
||||
|
||||
public LiveTvManager(
|
||||
IServerConfigurationManager config,
|
||||
ILogger<LiveTvManager> logger,
|
||||
IItemRepository itemRepo,
|
||||
IUserDataManager userDataManager,
|
||||
IDtoService dtoService,
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
ITaskManager taskManager,
|
||||
ILocalizationManager localization,
|
||||
IFileSystem fileSystem,
|
||||
IChannelManager channelManager,
|
||||
LiveTvDtoService liveTvDtoService,
|
||||
ITunerHostManager tunerHostManager)
|
||||
IEnumerable<ILiveTvService> services,
|
||||
IEnumerable<IListingsProvider> listingProviders)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_itemRepo = itemRepo;
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_taskManager = taskManager;
|
||||
_localization = localization;
|
||||
_fileSystem = fileSystem;
|
||||
_dtoService = dtoService;
|
||||
_userDataManager = userDataManager;
|
||||
_channelManager = channelManager;
|
||||
_tvDtoService = liveTvDtoService;
|
||||
_tunerHostManager = tunerHostManager;
|
||||
_services = services.ToArray();
|
||||
_listingProviders = listingProviders.ToArray();
|
||||
|
||||
var defaultService = _services.OfType<EmbyTV.EmbyTV>().First();
|
||||
defaultService.TimerCreated += OnEmbyTvTimerCreated;
|
||||
defaultService.TimerCancelled += OnEmbyTvTimerCancelled;
|
||||
}
|
||||
|
||||
public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled;
|
||||
|
@ -114,23 +103,6 @@ namespace Jellyfin.LiveTv
|
|||
return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<IListingsProvider> listingProviders)
|
||||
{
|
||||
_services = services.ToArray();
|
||||
|
||||
_listingProviders = listingProviders.ToArray();
|
||||
|
||||
foreach (var service in _services)
|
||||
{
|
||||
if (service is EmbyTV.EmbyTV embyTv)
|
||||
{
|
||||
embyTv.TimerCreated += OnEmbyTvTimerCreated;
|
||||
embyTv.TimerCancelled += OnEmbyTvTimerCancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs<string> e)
|
||||
{
|
||||
var timerId = e.Argument;
|
||||
|
@ -151,7 +123,7 @@ namespace Jellyfin.LiveTv
|
|||
|
||||
public QueryResult<BaseItem> GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = query.UserId.IsEmpty()
|
||||
var user = query.UserId.Equals(default)
|
||||
? null
|
||||
: _userManager.GetUserById(query.UserId);
|
||||
|
||||
|
@ -401,355 +373,6 @@ namespace Jellyfin.LiveTv
|
|||
}
|
||||
}
|
||||
|
||||
private async Task<LiveTvChannel> GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
var parentFolderId = parentFolder.Id;
|
||||
var isNew = false;
|
||||
var forceUpdate = false;
|
||||
|
||||
var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id);
|
||||
|
||||
var item = _libraryManager.GetItemById(id) as LiveTvChannel;
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
item = new LiveTvChannel
|
||||
{
|
||||
Name = channelInfo.Name,
|
||||
Id = id,
|
||||
DateCreated = DateTime.UtcNow
|
||||
};
|
||||
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
if (channelInfo.Tags is not null)
|
||||
{
|
||||
if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
item.Tags = channelInfo.Tags;
|
||||
}
|
||||
|
||||
if (!item.ParentId.Equals(parentFolderId))
|
||||
{
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
item.ParentId = parentFolderId;
|
||||
|
||||
item.ChannelType = channelInfo.ChannelType;
|
||||
item.ServiceName = serviceName;
|
||||
|
||||
if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.SetProviderId(ExternalServiceTag, serviceName);
|
||||
|
||||
if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.ExternalId = channelInfo.Id;
|
||||
|
||||
if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.Number = channelInfo.Number;
|
||||
|
||||
if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.Name = channelInfo.Name;
|
||||
|
||||
if (!item.HasImage(ImageType.Primary))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
|
||||
{
|
||||
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
|
||||
forceUpdate = true;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
|
||||
{
|
||||
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
|
||||
forceUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_libraryManager.CreateItem(item, parentFolder);
|
||||
}
|
||||
else if (forceUpdate)
|
||||
{
|
||||
await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel)
|
||||
{
|
||||
var id = _tvDtoService.GetInternalProgramId(info.Id);
|
||||
|
||||
var isNew = false;
|
||||
var forceUpdate = false;
|
||||
|
||||
if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item))
|
||||
{
|
||||
isNew = true;
|
||||
item = new LiveTvProgram
|
||||
{
|
||||
Name = info.Name,
|
||||
Id = id,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(info.Etag))
|
||||
{
|
||||
item.SetProviderId(EtagKey, info.Etag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.ShowId = info.ShowId;
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
var seriesId = info.SeriesId;
|
||||
|
||||
if (!item.ParentId.Equals(channel.Id))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.ParentId = channel.Id;
|
||||
|
||||
item.Audio = info.Audio;
|
||||
item.ChannelId = channel.Id;
|
||||
item.CommunityRating ??= info.CommunityRating;
|
||||
if ((item.CommunityRating ?? 0).Equals(0))
|
||||
{
|
||||
item.CommunityRating = null;
|
||||
}
|
||||
|
||||
item.EpisodeTitle = info.EpisodeTitle;
|
||||
item.ExternalId = info.Id;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal))
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.ExternalSeriesId = seriesId;
|
||||
|
||||
var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle);
|
||||
|
||||
if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle))
|
||||
{
|
||||
item.SeriesName = info.Name;
|
||||
}
|
||||
|
||||
var tags = new List<string>();
|
||||
if (info.IsLive)
|
||||
{
|
||||
tags.Add("Live");
|
||||
}
|
||||
|
||||
if (info.IsPremiere)
|
||||
{
|
||||
tags.Add("Premiere");
|
||||
}
|
||||
|
||||
if (info.IsNews)
|
||||
{
|
||||
tags.Add("News");
|
||||
}
|
||||
|
||||
if (info.IsSports)
|
||||
{
|
||||
tags.Add("Sports");
|
||||
}
|
||||
|
||||
if (info.IsKids)
|
||||
{
|
||||
tags.Add("Kids");
|
||||
}
|
||||
|
||||
if (info.IsRepeat)
|
||||
{
|
||||
tags.Add("Repeat");
|
||||
}
|
||||
|
||||
if (info.IsMovie)
|
||||
{
|
||||
tags.Add("Movie");
|
||||
}
|
||||
|
||||
if (isSeries)
|
||||
{
|
||||
tags.Add("Series");
|
||||
}
|
||||
|
||||
item.Tags = tags.ToArray();
|
||||
|
||||
item.Genres = info.Genres.ToArray();
|
||||
|
||||
if (info.IsHD ?? false)
|
||||
{
|
||||
item.Width = 1280;
|
||||
item.Height = 720;
|
||||
}
|
||||
|
||||
item.IsMovie = info.IsMovie;
|
||||
item.IsRepeat = info.IsRepeat;
|
||||
|
||||
if (item.IsSeries != isSeries)
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.IsSeries = isSeries;
|
||||
|
||||
item.Name = info.Name;
|
||||
item.OfficialRating ??= info.OfficialRating;
|
||||
item.Overview ??= info.Overview;
|
||||
item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks;
|
||||
item.ProviderIds = info.ProviderIds;
|
||||
|
||||
foreach (var providerId in info.SeriesProviderIds)
|
||||
{
|
||||
info.ProviderIds["Series" + providerId.Key] = providerId.Value;
|
||||
}
|
||||
|
||||
if (item.StartDate != info.StartDate)
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.StartDate = info.StartDate;
|
||||
|
||||
if (item.EndDate != info.EndDate)
|
||||
{
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
item.EndDate = info.EndDate;
|
||||
|
||||
item.ProductionYear = info.ProductionYear;
|
||||
|
||||
if (!isSeries || info.IsRepeat)
|
||||
{
|
||||
item.PremiereDate = info.OriginalAirDate;
|
||||
}
|
||||
|
||||
item.IndexNumber = info.EpisodeNumber;
|
||||
item.ParentIndexNumber = info.SeasonNumber;
|
||||
|
||||
if (!item.HasImage(ImageType.Primary))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.ImagePath))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.ImagePath,
|
||||
Type = ImageType.Primary
|
||||
},
|
||||
0);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.ImageUrl,
|
||||
Type = ImageType.Primary
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.HasImage(ImageType.Thumb))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.ThumbImageUrl,
|
||||
Type = ImageType.Thumb
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.HasImage(ImageType.Logo))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.LogoImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.LogoImageUrl,
|
||||
Type = ImageType.Logo
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.HasImage(ImageType.Backdrop))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl))
|
||||
{
|
||||
item.SetImage(
|
||||
new ItemImageInfo
|
||||
{
|
||||
Path = info.BackdropImageUrl,
|
||||
Type = ImageType.Backdrop
|
||||
},
|
||||
0);
|
||||
}
|
||||
}
|
||||
|
||||
var isUpdated = false;
|
||||
if (isNew)
|
||||
{
|
||||
}
|
||||
else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
|
||||
{
|
||||
isUpdated = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var etag = info.Etag;
|
||||
|
||||
if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.SetProviderId(EtagKey, etag);
|
||||
isUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNew || isUpdated)
|
||||
{
|
||||
item.OnMetadataChanged();
|
||||
}
|
||||
|
||||
return (item, isNew, isUpdated);
|
||||
}
|
||||
|
||||
public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null)
|
||||
{
|
||||
var program = _libraryManager.GetItemById(id);
|
||||
|
@ -1001,293 +624,6 @@ namespace Jellyfin.LiveTv
|
|||
}
|
||||
}
|
||||
|
||||
internal Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
return RefreshChannelsInternal(progress, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RefreshChannelsInternal(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false);
|
||||
|
||||
await _tunerHostManager.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var numComplete = 0;
|
||||
double progressPerService = _services.Length == 0
|
||||
? 0
|
||||
: 1.0 / _services.Length;
|
||||
|
||||
var newChannelIdList = new List<Guid>();
|
||||
var newProgramIdList = new List<Guid>();
|
||||
|
||||
var cleanDatabase = true;
|
||||
|
||||
foreach (var service in _services)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug("Refreshing guide from {Name}", service.Name);
|
||||
|
||||
try
|
||||
{
|
||||
var innerProgress = new ActionableProgress<double>();
|
||||
innerProgress.RegisterAction(p => progress.Report(p * progressPerService));
|
||||
|
||||
var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
newChannelIdList.AddRange(idList.Item1);
|
||||
newProgramIdList.AddRange(idList.Item2);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
cleanDatabase = false;
|
||||
_logger.LogError(ex, "Error refreshing channels for service");
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete;
|
||||
percent /= _services.Length;
|
||||
|
||||
progress.Report(100 * percent);
|
||||
}
|
||||
|
||||
if (cleanDatabase)
|
||||
{
|
||||
CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken);
|
||||
CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken);
|
||||
}
|
||||
|
||||
var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault();
|
||||
|
||||
if (coreService is not null)
|
||||
{
|
||||
await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false);
|
||||
await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Load these now which will prefetch metadata
|
||||
var dtoOptions = new DtoOptions();
|
||||
var fields = dtoOptions.Fields.ToList();
|
||||
dtoOptions.Fields = fields.ToArray();
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
progress.Report(10);
|
||||
|
||||
var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
|
||||
.Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
|
||||
.ToList();
|
||||
|
||||
var list = new List<LiveTvChannel>();
|
||||
|
||||
var numComplete = 0;
|
||||
var parentFolder = GetInternalLiveTvFolder(cancellationToken);
|
||||
|
||||
foreach (var channelInfo in allChannelsList)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
list.Add(item);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete;
|
||||
percent /= allChannelsList.Count;
|
||||
|
||||
progress.Report((5 * percent) + 10);
|
||||
}
|
||||
|
||||
progress.Report(15);
|
||||
|
||||
numComplete = 0;
|
||||
var programs = new List<Guid>();
|
||||
var channels = new List<Guid>();
|
||||
|
||||
var guideDays = GetGuideDays();
|
||||
|
||||
_logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var currentChannel in list)
|
||||
{
|
||||
channels.Add(currentChannel.Id);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var start = DateTime.UtcNow.AddHours(-1);
|
||||
var end = start.AddDays(guideDays);
|
||||
|
||||
var isMovie = false;
|
||||
var isSports = false;
|
||||
var isNews = false;
|
||||
var isKids = false;
|
||||
var iSSeries = false;
|
||||
|
||||
var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
|
||||
var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
|
||||
ChannelIds = new Guid[] { currentChannel.Id },
|
||||
DtoOptions = new DtoOptions(true)
|
||||
}).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
|
||||
|
||||
var newPrograms = new List<LiveTvProgram>();
|
||||
var updatedPrograms = new List<BaseItem>();
|
||||
|
||||
foreach (var program in channelPrograms)
|
||||
{
|
||||
var programTuple = GetProgram(program, existingPrograms, currentChannel);
|
||||
var programItem = programTuple.Item;
|
||||
|
||||
if (programTuple.IsNew)
|
||||
{
|
||||
newPrograms.Add(programItem);
|
||||
}
|
||||
else if (programTuple.IsUpdated)
|
||||
{
|
||||
updatedPrograms.Add(programItem);
|
||||
}
|
||||
|
||||
programs.Add(programItem.Id);
|
||||
|
||||
isMovie |= program.IsMovie;
|
||||
iSSeries |= program.IsSeries;
|
||||
isSports |= program.IsSports;
|
||||
isNews |= program.IsNews;
|
||||
isKids |= program.IsKids;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count);
|
||||
|
||||
if (newPrograms.Count > 0)
|
||||
{
|
||||
_libraryManager.CreateItems(newPrograms, null, cancellationToken);
|
||||
}
|
||||
|
||||
if (updatedPrograms.Count > 0)
|
||||
{
|
||||
await _libraryManager.UpdateItemsAsync(
|
||||
updatedPrograms,
|
||||
currentChannel,
|
||||
ItemUpdateType.MetadataImport,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
currentChannel.IsMovie = isMovie;
|
||||
currentChannel.IsNews = isNews;
|
||||
currentChannel.IsSports = isSports;
|
||||
currentChannel.IsSeries = iSSeries;
|
||||
|
||||
if (isKids)
|
||||
{
|
||||
currentChannel.AddTag("Kids");
|
||||
}
|
||||
|
||||
await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
|
||||
await currentChannel.RefreshMetadata(
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ForceSave = true
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete / (double)allChannelsList.Count;
|
||||
|
||||
progress.Report((85 * percent) + 15);
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
return new Tuple<List<Guid>, List<Guid>>(channels, programs);
|
||||
}
|
||||
|
||||
private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _itemRepo.GetItemIdsList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = validTypes,
|
||||
DtoOptions = new DtoOptions(false)
|
||||
});
|
||||
|
||||
var numComplete = 0;
|
||||
|
||||
foreach (var itemId in list)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (itemId.IsEmpty())
|
||||
{
|
||||
// Somehow some invalid data got into the db. It probably predates the boundary checking
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentIdList.Contains(itemId))
|
||||
{
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
|
||||
if (item is not null)
|
||||
{
|
||||
_libraryManager.DeleteItem(
|
||||
item,
|
||||
new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false,
|
||||
DeleteFromExternalProvider = false
|
||||
},
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
double percent = numComplete / (double)list.Count;
|
||||
|
||||
progress.Report(100 * percent);
|
||||
}
|
||||
}
|
||||
|
||||
private double GetGuideDays()
|
||||
{
|
||||
var config = _config.GetLiveTvConfiguration();
|
||||
|
||||
if (config.GuideDays.HasValue)
|
||||
{
|
||||
return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays));
|
||||
}
|
||||
|
||||
return 7;
|
||||
}
|
||||
|
||||
private async Task<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user)
|
||||
{
|
||||
if (user is null)
|
||||
|
@ -1505,7 +841,7 @@ namespace Jellyfin.LiveTv
|
|||
|
||||
public async Task<QueryResult<BaseItemDto>> GetRecordingsAsync(RecordingQuery query, DtoOptions options)
|
||||
{
|
||||
var user = query.UserId.IsEmpty()
|
||||
var user = query.UserId.Equals(default)
|
||||
? null
|
||||
: _userManager.GetUserById(query.UserId);
|
||||
|
||||
|
@ -1819,12 +1155,6 @@ namespace Jellyfin.LiveTv
|
|||
return new QueryResult<SeriesTimerInfoDto>(returnArray);
|
||||
}
|
||||
|
||||
public BaseItem GetLiveTvChannel(TimerInfo timer, ILiveTvService service)
|
||||
{
|
||||
var internalChannelId = _tvDtoService.GetInternalChannelId(service.Name, timer.ChannelId);
|
||||
return _libraryManager.GetItemById(internalChannelId);
|
||||
}
|
||||
|
||||
public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
@ -2057,18 +1387,6 @@ namespace Jellyfin.LiveTv
|
|||
await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public GuideInfo GetGuideInfo()
|
||||
{
|
||||
var startDate = DateTime.UtcNow;
|
||||
var endDate = startDate.AddDays(GetGuideDays());
|
||||
|
||||
return new GuideInfo
|
||||
{
|
||||
StartDate = startDate,
|
||||
EndDate = endDate
|
||||
};
|
||||
}
|
||||
|
||||
private LiveTvServiceInfo[] GetServiceInfos()
|
||||
{
|
||||
return Services.Select(GetServiceInfo).ToArray();
|
||||
|
@ -2302,16 +1620,6 @@ namespace Jellyfin.LiveTv
|
|||
return provider.GetChannels(info, cancellationToken);
|
||||
}
|
||||
|
||||
public Guid GetInternalChannelId(string serviceName, string externalId)
|
||||
{
|
||||
return _tvDtoService.GetInternalChannelId(serviceName, externalId);
|
||||
}
|
||||
|
||||
public Guid GetInternalProgramId(string externalId)
|
||||
{
|
||||
return _tvDtoService.GetInternalProgramId(externalId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BaseItem[]> GetRecordingFoldersAsync(User user)
|
||||
=> GetRecordingFoldersAsync(user, false);
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
@ -10,34 +6,44 @@ using Jellyfin.Data.Enums;
|
|||
using Jellyfin.Data.Events;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.LiveTv
|
||||
{
|
||||
public sealed class RecordingNotifier : IServerEntryPoint
|
||||
/// <summary>
|
||||
/// <see cref="IHostedService"/> responsible for notifying users when a LiveTV recording is completed.
|
||||
/// </summary>
|
||||
public sealed class RecordingNotifier : IHostedService
|
||||
{
|
||||
private readonly ILiveTvManager _liveTvManager;
|
||||
private readonly ILogger<RecordingNotifier> _logger;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<RecordingNotifier> _logger;
|
||||
private readonly ILiveTvManager _liveTvManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecordingNotifier"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The <see cref="ILogger"/>.</param>
|
||||
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
|
||||
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
|
||||
/// <param name="liveTvManager">The <see cref="ILiveTvManager"/>.</param>
|
||||
public RecordingNotifier(
|
||||
ILogger<RecordingNotifier> logger,
|
||||
ISessionManager sessionManager,
|
||||
IUserManager userManager,
|
||||
ILogger<RecordingNotifier> logger,
|
||||
ILiveTvManager liveTvManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_liveTvManager = liveTvManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RunAsync()
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled;
|
||||
_liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled;
|
||||
|
@ -47,29 +53,35 @@ namespace Jellyfin.LiveTv
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void OnLiveTvManagerSeriesTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e)
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false);
|
||||
_liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled;
|
||||
_liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled;
|
||||
_liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated;
|
||||
_liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e)
|
||||
{
|
||||
await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false);
|
||||
}
|
||||
private async void OnLiveTvManagerSeriesTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||
=> await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false);
|
||||
|
||||
private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e)
|
||||
{
|
||||
await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false);
|
||||
}
|
||||
private async void OnLiveTvManagerTimerCreated(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||
=> await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false);
|
||||
|
||||
private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e)
|
||||
{
|
||||
await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false);
|
||||
}
|
||||
private async void OnLiveTvManagerSeriesTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||
=> await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false);
|
||||
|
||||
private async void OnLiveTvManagerTimerCancelled(object? sender, GenericEventArgs<TimerEventInfo> e)
|
||||
=> await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false);
|
||||
|
||||
private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
|
||||
{
|
||||
var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList();
|
||||
var users = _userManager.Users
|
||||
.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess))
|
||||
.Select(i => i.Id)
|
||||
.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -80,14 +92,5 @@ namespace Jellyfin.LiveTv
|
|||
_logger.LogError(ex, "Error sending message");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled;
|
||||
_liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled;
|
||||
_liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated;
|
||||
_liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
|
||||
namespace Jellyfin.LiveTv
|
||||
{
|
||||
/// <summary>
|
||||
/// The "Refresh Guide" scheduled task.
|
||||
/// </summary>
|
||||
public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask
|
||||
{
|
||||
private readonly ILiveTvManager _liveTvManager;
|
||||
private readonly IConfigurationManager _config;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="liveTvManager">The live tv manager.</param>
|
||||
/// <param name="config">The configuration manager.</param>
|
||||
public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config)
|
||||
{
|
||||
_liveTvManager = liveTvManager;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Refresh Guide";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Downloads channel information from live tv services.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "Live TV";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsHidden => _liveTvManager.Services.Count == 1 && _config.GetLiveTvConfiguration().TunerHosts.Length == 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLogged => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "RefreshGuide";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var manager = (LiveTvManager)_liveTvManager;
|
||||
|
||||
return manager.RefreshChannels(progress, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
// Every so often
|
||||
new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ using System.Text.Json;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using Jellyfin.LiveTv.Guide;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
|
@ -12,36 +8,34 @@ using System.Threading.Tasks;
|
|||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mono.Nat;
|
||||
|
||||
namespace Jellyfin.Networking;
|
||||
|
||||
/// <summary>
|
||||
/// Server entrypoint handling external port forwarding.
|
||||
/// <see cref="IHostedService"/> responsible for UPnP port forwarding.
|
||||
/// </summary>
|
||||
public sealed class ExternalPortForwarding : IServerEntryPoint
|
||||
public sealed class PortForwardingHost : IHostedService, IDisposable
|
||||
{
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly ILogger<ExternalPortForwarding> _logger;
|
||||
private readonly ILogger<PortForwardingHost> _logger;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new();
|
||||
|
||||
private readonly ConcurrentDictionary<IPEndPoint, byte> _createdRules = new ConcurrentDictionary<IPEndPoint, byte>();
|
||||
|
||||
private Timer _timer;
|
||||
private string _configIdentifier;
|
||||
|
||||
private Timer? _timer;
|
||||
private string? _configIdentifier;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExternalPortForwarding"/> class.
|
||||
/// Initializes a new instance of the <see cref="PortForwardingHost"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="appHost">The application host.</param>
|
||||
/// <param name="config">The configuration manager.</param>
|
||||
public ExternalPortForwarding(
|
||||
ILogger<ExternalPortForwarding> logger,
|
||||
public PortForwardingHost(
|
||||
ILogger<PortForwardingHost> logger,
|
||||
IServerApplicationHost appHost,
|
||||
IServerConfigurationManager config)
|
||||
{
|
||||
|
@ -66,7 +60,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint
|
|||
.ToString();
|
||||
}
|
||||
|
||||
private void OnConfigurationUpdated(object sender, EventArgs e)
|
||||
private void OnConfigurationUpdated(object? sender, EventArgs e)
|
||||
{
|
||||
var oldConfigIdentifier = _configIdentifier;
|
||||
_configIdentifier = GetConfigIdentifier();
|
||||
|
@ -79,7 +73,7 @@ public sealed class ExternalPortForwarding : IServerEntryPoint
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RunAsync()
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Start();
|
||||
|
||||
|
@ -88,6 +82,14 @@ public sealed class ExternalPortForwarding : IServerEntryPoint
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Stop();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
var config = _config.GetNetworkConfiguration();
|
||||
|
@ -101,7 +103,8 @@ public sealed class ExternalPortForwarding : IServerEntryPoint
|
|||
NatUtility.DeviceFound += OnNatUtilityDeviceFound;
|
||||
NatUtility.StartDiscovery();
|
||||
|
||||
_timer = new Timer((_) => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10));
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(_ => _createdRules.Clear(), null, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
|
@ -112,13 +115,23 @@ public sealed class ExternalPortForwarding : IServerEntryPoint
|
|||
NatUtility.DeviceFound -= OnNatUtilityDeviceFound;
|
||||
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
private async void OnNatUtilityDeviceFound(object sender, DeviceEventArgs e)
|
||||
private async void OnNatUtilityDeviceFound(object? sender, DeviceEventArgs e)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
try
|
||||
{
|
||||
await CreateRules(e.Device).ConfigureAwait(false);
|
||||
// On some systems the device discovered event seems to fire repeatedly
|
||||
// This check will help ensure we're not trying to port map the same device over and over
|
||||
if (!_createdRules.TryAdd(e.Device.DeviceEndpoint, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.WhenAll(CreatePortMaps(e.Device)).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@ -126,20 +139,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint
|
|||
}
|
||||
}
|
||||
|
||||
private Task CreateRules(INatDevice device)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// On some systems the device discovered event seems to fire repeatedly
|
||||
// This check will help ensure we're not trying to port map the same device over and over
|
||||
if (!_createdRules.TryAdd(device.DeviceEndpoint, 0))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return Task.WhenAll(CreatePortMaps(device));
|
||||
}
|
||||
|
||||
private IEnumerable<Task> CreatePortMaps(INatDevice device)
|
||||
{
|
||||
var config = _config.GetNetworkConfiguration();
|
||||
|
@ -185,8 +184,6 @@ public sealed class ExternalPortForwarding : IServerEntryPoint
|
|||
|
||||
_config.ConfigurationUpdated -= OnConfigurationUpdated;
|
||||
|
||||
Stop();
|
||||
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using Jellyfin.Api.Controllers;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Api.Tests.Controllers
|
||||
{
|
||||
public class SystemControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetLogFile_FileDoesNotExist_ReturnsNotFound()
|
||||
{
|
||||
var mockFileSystem = new Mock<IFileSystem>();
|
||||
mockFileSystem
|
||||
.Setup(fs => fs.GetFiles(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns([new() { Name = "file1.txt" }, new() { Name = "file2.txt" }]);
|
||||
|
||||
var controller = new SystemController(
|
||||
Mock.Of<ILogger<SystemController>>(),
|
||||
Mock.Of<IServerApplicationHost>(),
|
||||
Mock.Of<IServerApplicationPaths>(),
|
||||
mockFileSystem.Object,
|
||||
Mock.Of<INetworkManager>(),
|
||||
Mock.Of<ISystemManager>());
|
||||
|
||||
var result = controller.GetLogFile("DOES_NOT_EXIST.txt");
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(result);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue